From b03cb324d5cc5e91a55b5eb9045b8ead244aaf11 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 17 Apr 2026 02:49:44 +0200 Subject: [PATCH 01/52] Python: Add Hyperlight CodeAct package and docs (#5185) * initial work on code_mode * updated samples * updates to codeact * udpated codeact * Draft CodeAct ADR and sample updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * initial implementation and adr and feature * Python: Limit Hyperlight wasm backend to Python <3.14 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix CI for Hyperlight CodeAct PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Run Hyperlight integration when available Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Address Hyperlight review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Simplify Hyperlight file mount inputs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Accept Path host paths in Hyperlight mounts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix Hyperlight mount typing for CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * temp run integration test * Python: Strengthen Hyperlight real sandbox tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * added additional tests * Python: Simplify Hyperlight CodeAct API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * set tests as non-integration * Retry Hyperlight allowed-domain registration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Gate Hyperlight integration tests by runtime support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Hyperlight skip test on Python 3.14 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Delay Hyperlight runtime probe until test execution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Relax Hyperlight Windows integration stdout assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Scan Hyperlight output directory for artifacts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retry Hyperlight output artifact collection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Hyperlight integration output assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Retry Hyperlight read-back check in integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify Hyperlight integration write assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Avoid pathlib in Hyperlight integration sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use socket network check in Hyperlight sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace blocked Azure AI Search blog link Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify Hyperlight guest stdlib limits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use _socket in Hyperlight integration sandbox Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle Hyperlight mounted file paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Broaden Hyperlight sandbox path fallbacks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Search Hyperlight guest mounts recursively Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Split Hyperlight mount coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Split Hyperlight live network tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix Hyperlight file-write test on Windows Enable the sandbox filesystem by providing a workspace_root so /output is mounted. Remove os.path.exists assertion (unsupported in WASM guest) and fix Content data assertion to use .uri. Skip the network integration test on Windows where the WASM sandbox lacks the encodings.idna codec. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: ADR intro, manual wiring sample, doc clarifications - Add CodeAct introduction section to ADR for unfamiliar readers - Clarify 'less runtime efficient' con with specific overhead description - Add note in Python impl doc clarifying ADR vs impl doc split - Explain why before_run hooks must be per-run (CRUD, concurrency, approval) - Rename code_interpreter variable to codeact in E2E sample - Add manual static wiring sample (codeact_manual_wiring.py) - Add 'when to use which pattern' guidance to samples README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #5185 review comments and add .NET CodeAct design doc - Fix async callback: _make_sandbox_callback returns sync wrapper with thread + asyncio.run() bridge (was broken with real Wasm FFI) - Fix stale output: clear output_dir before each sandbox.run() call - Fix blocking event loop: _run_code now async with asyncio.to_thread() - Revert _agents.py options['tools'] injection (unnecessary; provider uses context.extend_tools()) - Revert SessionContext.options docstring back to read-only - Add real-sandbox test fixtures (shared/restored/fresh) - Add 8 new real-sandbox tests for callback round-trip, stale output, event loop non-blocking, basic execution, stdout/stderr, errors, snapshot/restore, and tool registration - Add comprehensive .NET HyperlightCodeActProvider design document Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update hyperlight README with code snippets and remove Public API section Replace bare export list with Quick Start code examples covering the context provider, standalone tool, manual static wiring, and file mounts / network access patterns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 5 +- .github/workflows/python-merge-tests.yml | 4 +- docs/decisions/0024-codeact-integration.md | 233 +++++ .../code_act/dotnet-implementation.md | 625 +++++++++++ .../code_act/python-implementation.md | 385 +++++++ python/.cspell.json | 2 + python/PACKAGE_STATUS.md | 1 + .../packages/core/agent_framework/_tools.py | 9 +- python/packages/hyperlight/LICENSE | 21 + python/packages/hyperlight/README.md | 132 +++ .../agent_framework_hyperlight/__init__.py | 24 + .../_execute_code_tool.py | 860 +++++++++++++++ .../_instructions.py | 126 +++ .../agent_framework_hyperlight/_provider.py | 111 ++ .../agent_framework_hyperlight/_types.py | 28 + .../agent_framework_hyperlight/py.typed | 1 + python/packages/hyperlight/pyproject.toml | 101 ++ python/packages/hyperlight/samples/README.md | 43 + .../samples/codeact_context_provider.py | 192 ++++ .../samples/codeact_manual_wiring.py | 133 +++ .../hyperlight/samples/codeact_tool.py | 110 ++ .../hyperlight/test_hyperlight_codeact.py | 981 ++++++++++++++++++ python/pyproject.toml | 1 + .../azure_ai_search/README.md | 4 +- python/uv.lock | 53 + 25 files changed, 4176 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/0024-codeact-integration.md create mode 100644 docs/features/code_act/dotnet-implementation.md create mode 100644 docs/features/code_act/python-implementation.md create mode 100644 python/packages/hyperlight/LICENSE create mode 100644 python/packages/hyperlight/README.md create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/__init__.py create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/_instructions.py create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/_provider.py create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/_types.py create mode 100644 python/packages/hyperlight/agent_framework_hyperlight/py.typed create mode 100644 python/packages/hyperlight/pyproject.toml create mode 100644 python/packages/hyperlight/samples/README.md create mode 100644 python/packages/hyperlight/samples/codeact_context_provider.py create mode 100644 python/packages/hyperlight/samples/codeact_manual_wiring.py create mode 100644 python/packages/hyperlight/samples/codeact_tool.py create mode 100644 python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index 3c6c620614..f2fb5c6448 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -131,7 +131,7 @@ jobs: --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 - # Misc integration tests (Anthropic, Ollama, MCP) + # Misc integration tests (Anthropic, Hyperlight, Ollama, MCP) python-tests-misc-integration: name: Python Integration Tests - Misc runs-on: ubuntu-latest @@ -162,10 +162,11 @@ jobs: fallback_url: ${{ env.LOCAL_MCP_URL }} - name: Prefer local MCP URL when available run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV" - - name: Test with pytest (Anthropic, Ollama, MCP integration) + - name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration) run: > uv run pytest --import-mode=importlib packages/anthropic/tests + packages/hyperlight/tests packages/ollama/tests packages/core/tests/core/test_mcp.py -m integration diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index 454b297bed..dd48b268df 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -65,6 +65,7 @@ jobs: - 'python/samples/**/providers/azure/**' misc: - 'python/packages/anthropic/**' + - 'python/packages/hyperlight/**' - 'python/packages/ollama/**' - 'python/packages/core/agent_framework/_mcp.py' - 'python/packages/core/tests/core/test_mcp.py' @@ -278,10 +279,11 @@ jobs: fallback_url: ${{ env.LOCAL_MCP_URL }} - name: Prefer local MCP URL when available run: echo "LOCAL_MCP_URL=${{ steps.local-mcp.outputs.effective_url }}" >> "$GITHUB_ENV" - - name: Test with pytest (Anthropic, Ollama, MCP integration) + - name: Test with pytest (Anthropic, Hyperlight, Ollama, MCP integration) run: > uv run pytest --import-mode=importlib packages/anthropic/tests + packages/hyperlight/tests packages/ollama/tests packages/core/tests/core/test_mcp.py -m integration diff --git a/docs/decisions/0024-codeact-integration.md b/docs/decisions/0024-codeact-integration.md new file mode 100644 index 0000000000..b83af6a17e --- /dev/null +++ b/docs/decisions/0024-codeact-integration.md @@ -0,0 +1,233 @@ +--- +status: proposed +contact: eavanvalkenburg +date: 2026-04-07 +deciders: TBD +consulted: +informed: +--- + +# CodeAct integration through backend-specific context providers and an `execute_code` tool + +## Introduction + +**CodeAct** is a pattern in which the model writes executable code — rather than emitting a fixed function-call JSON schema — to plan, transform data, and orchestrate tool calls inside a single sandbox invocation. Instead of requiring a separate model round-trip for every tool call, conditional branch, or data transformation, the model produces a short program that runs in a controlled runtime, calls host-provided tools through a `call_tool(...)` bridge, and returns structured results. This reduces latency, lowers token cost, and lets the model express richer multi-step logic that is difficult to capture in a flat tool-call sequence. + +Throughout this ADR, **CodeAct** is the primary term. **Code mode** and **programmatic tool calling** refer to the same capability. + +## Context and Problem Statement + +We need an architecture design that supports CodeAct in both Python and .NET. This is a necessary capability for the current generation of long-running agents, which need to plan, iterate, transform tool outputs, and execute bounded code inside a controlled runtime — for example, filtering a large result set, computing derived values, or chaining several tool calls with conditional logic — instead of requiring a separate model round-trip for each of those steps. The design should preserve the same behavioral contract across SDKs, but it does not need to use the same internal extension point in each runtime. We also want to standardize on Hyperlight as the initial backend, using the existing Python package and an anticipated .NET binding package once it is available. + +Throughout this ADR, **CodeAct** is the primary term. **Code mode** and **programmatic tool calling** refer to the same capability. This ADR uses **CodeAct** consistently. + +Model-generated code is treated as untrusted relative to the host process. This ADR assumes the selected backend provides the primary isolation boundary, while the framework is responsible for configuring approvals and capabilities, integrating telemetry, and translating outputs and failures into framework-native shapes. If a backend cannot provide isolation appropriate for its trust model, it is not a suitable CodeAct backend. + +The core design question is: **where should CodeAct integrate into the agent pipeline so that both SDKs can offer the same functionality without invasive changes to their core function-calling loops?** + +## Decision Drivers + +- CodeAct must shape the model-facing surface before model invocation, not only after the model has already chosen tools. +- The design should let users control which tools are available through CodeAct and which remain regular tools only. +- The design must preserve existing session, approval, telemetry, and tool invocation behavior as much as possible. +- The design should define the minimum cross-SDK telemetry and failure semantics for `execute_code`, so Python and .NET do not diverge on basic observability or error handling. +- The design must fit naturally into the extension points that already exist in each SDK. +- The design must be safe for concurrent runs and must not rely on mutating shared agent configuration during invocation. +- The chosen structure should allow multiple backend-specific providers to fit under the same conceptual design over time, even though Hyperlight is the initial target. +- The abstraction should not assume that every backend is a VM-style sandbox; alternative execution models such as Pydantic's Monty should also fit. +- The design should allow `execute_code` to be reused both as a tool-enabled CodeAct runtime and as a standard code interpreter tool implementation. +- The design should remain open to alternative language/runtime modes, such as JavaScript on Hyperlight, rather than baking the abstraction to Python only. +- The design should provide a portable way to configure sandbox capabilities such as file access and network access, including allow-listed outbound domains. +- Using CodeAct should be optional, and installing its runtime or backend dependencies should also be optional. +- Backend-specific dependencies should be isolated behind a small adapter so SDK code is not tightly coupled to an unstable package surface. + +## Considered Options + +- **Option 1**: Standardize on context provider-based CodeAct with a shared cross-SDK contract and backend-specific public types +- **Option 2**: Implement CodeAct as a dedicated chat-client decorator/wrapper +- **Option 3**: Integrate CodeAct directly into the function invocation layer/FunctionInvokingChatClient + +## Pros and Cons of the Options + +### Option 1: Standardize on context provider-based CodeAct with a shared cross-SDK contract and backend-specific public types + +This option uses `ContextProvider` in Python and `AIContextProvider` in .NET, but standardizes the public concept and behavior. +In this option, the CodeAct tool set is provider-owned: only tools explicitly configured on the concrete CodeAct provider instance are available inside CodeAct, and the provider exposes direct CRUD-style management for tools, file mounts, and outbound network allow-list configuration rather than requiring a separate runtime setup object. +The agent's direct tool surface remains separate. If a tool should be available both through CodeAct and as a normal direct tool, it is configured in both places. + +- Good, because both SDKs already have first-class provider concepts intended for per-invocation context shaping. +- Good, because providers operate before model invocation, which is where CodeAct must add instructions and reshape tools. +- Good, because this lets us preserve existing function invocation behavior rather than rewriting it. +- Good, because slightly different internals are acceptable while the public behavior remains aligned. +- Good, because convenience builder/decorator helpers can still be added later on top of the provider model without changing the core design. +- Good, because backend-specific runtime logic can stay inside concrete provider implementations or internal helpers instead of being forced into a lowest-common-denominator public abstraction. +- Good, because the same provider structure can support either an all-or-nothing tool surface or a mixed side-by-side tool surface. +- Good, because users can keep some tools direct-only while allowing other tools to be used from inside CodeAct. +- Good, because a provider-owned CodeAct tool registry avoids mutating or inferring the agent's direct tool surface and can work consistently in both SDKs. +- Good, because the same conceptual design can remain open to `HyperlightCodeActProvider`, a future `MontyCodeActProvider`, and other backend-specific providers over time. +- Good, because `execute_code` can evolve into multiple backend-specific runtime modes rather than being hard-wired to one Python-plus-tools mode. +- Bad, because the provider indirection adds per-run overhead — snapshotting the tool registry, dispatching lifecycle hooks, and building instructions — that a deeper integration point could skip. In practice this overhead is negligible relative to model inference latency and sandbox startup cost. + +### Option 2: Implement CodeAct as a dedicated chat-client decorator/wrapper + +This option would introduce a CodeAct-specific chat-client decorator that injects instructions and tools directly into the chat request pipeline. + +- Good, because this is a natural fit for .NET's `DelegatingChatClient` pipeline. +- Good, because it can also support advanced custom chat-client stacks. +- Good, because backend-specific runtime selection could be hidden inside the decorator implementation. +- Good, because the decorator could also encapsulate mode-specific instruction shaping for tool-enabled versus standalone interpreter behavior. +- Good, because the decorator can decide per request whether the tool surface is exclusive or mixed. +- Bad, because Python can support this by building a custom layering stack on top of a `Raw...Client` and swapping in a different `FunctionInvocationLayer`, but that composition path is more manual than the .NET `DelegatingChatClient` pipeline. +- Bad, because it duplicates responsibilities already handled by provider abstractions. +- Bad, because it makes CodeAct look more transport-specific than it really is. +- Bad, because swappable backends and reusable interpreter or language modes become coupled to chat-client composition rather than modeled as first-class CodeAct concepts. + +### Option 3: Integrate CodeAct directly into the function invocation layer/FunctionInvokingChatClient + +This option would push CodeAct into Python's `FunctionInvocationLayer` and .NET's `FunctionInvokingChatClient` or related middleware. + +- Good, because it is close to tool execution and can observe concrete tool invocation behavior. +- Good, because function middleware may still be useful later for auxiliary auditing or policy around sandbox-originated tool calls. +- Bad, because this is the wrong layer for constructing the model-facing tool surface and prompt instructions. +- Bad, because it does not naturally control whether the model sees an exclusive CodeAct tool surface or a mixed side-by-side tool surface. +- Bad, because it would still require a second mechanism for hiding normal tools and advertising `execute_code`. +- Bad, because it is a weak fit for standalone interpreter modes where no tool-calling loop is needed. +- Bad, because backend selection and CodeAct mode behavior are orthogonal concerns that do not belong in the function invocation layer. +- Bad, because `.NET` would become more tightly coupled to `FunctionInvokingChatClient`, which sits below the agent framework abstraction and is not the natural cross-SDK design seam. + +## Approval Model Options + +- **Option A**: Bundled approval for the `execute_code` invocation +- **Option B**: Pre-execution inspection of `call_tool(...)` references before approving `execute_code` +- **Option C**: Nested per-tool approvals during `execute_code` + +## Pros and Cons of the Approval Options + +### Option A: Bundled approval for the `execute_code` invocation + +This option grants approval once, before `execute_code` starts. Provider-owned tool calls made from inside that execution run under the same approval. The effective approval of `execute_code` is determined up front from the provider configuration rather than from inspecting which tools are actually called during execution. + +- Good, because it is the simplest model to explain and implement consistently in both SDKs. +- Good, because it fits naturally with long-running CodeAct loops where repeated approval interruptions would be disruptive. +- Good, because it does not require static code analysis before execution begins. +- Good, because it keeps the first release focused on the provider integration rather than a more complex approval engine. +- Bad, because approval is coarse-grained and may cover more activity than the user expected. +- Bad, because it provides less visibility into which provider-owned tools or capabilities will be exercised during the run. + +### Option B: Pre-execution inspection of `call_tool(...)` references before approving `execute_code` + +This option inspects submitted code for statically discoverable `call_tool("tool_name", ...)` references before execution starts and uses that information to shape the approval request. + +- Good, because it can show users more detail up front while still keeping approval at a single pre-execution moment. +- Good, because it matches the common case where tool names are spelled out directly in the generated code. +- Good, because it can coexist with bundled approval as a more informative variant of the same UX. +- Bad, because the analysis is inherently best-effort and cannot reliably predict dynamic behavior. +- Bad, because it requires duplicated parsing or inspection logic that does not replace runtime enforcement. + +### Option C: Nested per-tool approvals during `execute_code` + +This option requests approval when sandboxed code actually attempts to invoke a provider-owned tool that requires approval. + +- Good, because it aligns approval with real behavior rather than predicted behavior. +- Good, because it gives precise visibility into which provider-owned tools are being used. +- Good, because it can allow some tool calls while rejecting others within the same execution. +- Bad, because it interrupts long-running CodeAct flows and can degrade the user experience significantly. +- Bad, because it requires more complex runtime plumbing and approval UX in both SDKs. +- Bad, because repeated approval pauses may make CodeAct less useful for the exact long-running scenarios that motivate this feature. + +## Decision Outcomes + +### Decision 1: Integration seam and public structure + +Chosen option: **Option 1: Standardize on provider-based CodeAct with a shared cross-SDK contract and backend-specific public types**, because it is the only option that maps cleanly to both SDKs, lets us reshape instructions and tools before model invocation, and avoids invasive changes to the existing function invocation loops while still allowing multiple backend-specific providers and multiple runtime modes to fit under the same structure later. + +### Decision 2: Initial approval model + +Chosen option: **Option A: Bundled approval for the `execute_code` invocation**, because it is the smallest approval model that fits both SDKs, works well for long-running CodeAct flows, and does not force us to standardize a more complex inspection or policy engine in the first release. + +This follows the spirit of the current Python tool approval flow, where `FunctionTool` uses `approval_mode="always_require" | "never_require"` and the auto-invocation loop escalates the whole batch when any called tool requires approval. + +### Design summary + +We standardize the **public concept** of CodeAct across SDKs while allowing each SDK to use the extension point that fits it best. + +- Python uses a `ContextProvider`. +- .NET uses an `AIContextProvider`. +- The term **CodeAct context provider** is used throughout this ADR as a design concept, not as a required public base type. Public SDK APIs should prefer concrete backend-specific types such as `HyperlightCodeActProvider` rather than a public abstract `CodeActContextProvider` or a public `CodeActExecutor` parameter. +- CodeAct support should ship as an optional package in each SDK rather than as part of the core package, so users who do not need CodeAct do not take on its installation and dependency footprint. That optional package may still depend on a few small, backward-compatible hooks in the host SDK's core agent pipeline. +- There is no separate runtime setup object in the chosen design. Concrete providers manage their provider-owned CodeAct tool registry, file mounts, and outbound network allow-list configuration directly through CRUD-style methods on the provider itself. +- At a high level, CodeAct is exposed through backend-specific context providers that contribute an `execute_code` tool, own the CodeAct-specific tool registry, and carry backend capability configuration such as filesystem and network access. +- The initial approval model is bundled approval for `execute_code`, using the same `approval_mode="always_require" | "never_require"` vocabulary as regular tools. +- The CodeAct provider exposes a default `approval_mode` for `execute_code`. If the provider default is `always_require`, `execute_code` is always treated as `always_require` regardless of the provider-owned tool registry. If the provider default is `never_require`, the effective approval for `execute_code` is derived from the provider-owned CodeAct tool registry captured for the run. +- If every provider-owned CodeAct tool in that registry has `approval_mode="never_require"`, `execute_code` is treated as `never_require`. If any provider-owned CodeAct tool in that registry has `approval_mode="always_require"`, `execute_code` is treated as `always_require`, even if the generated code may not end up calling that tool. +- Approval is granted before `execute_code` starts, and provider-owned tool calls made from inside that execution run under the same approval. +- Direct-only agent tools do not affect the approval of `execute_code`; only the provider-owned CodeAct tool registry participates in that calculation. +- This approval model is intentionally conservative. If one sensitive provider-owned tool forces `execute_code` to require approval more often than desired, the mitigation is to keep that tool direct-only or split it into a different provider/tool surface rather than trying to infer per-run tool usage up front. +- Configuring filesystem and network capability state on the provider, including adding file mounts or outbound network allow-list entries, is itself the approval for those capabilities in the initial model. +- Each `execute_code` invocation must start from a clean execution state; in-memory variables and other ephemeral interpreter/runtime state must not persist across separate calls. When a provider exposes a workspace, mounted files, or a writable artifact/output area, those files are the supported persistence mechanism across calls and are treated as external state rather than interpreter state. +- Mutating the provider's tool registry or capability configuration while a run is in flight is allowed, but it only affects subsequent runs. Provider implementations must snapshot the effective state for each run and synchronize concurrent access so shared provider instances remain safe across concurrent runs. +- The minimum cross-SDK telemetry contract is that `execute_code` is traced as a normal tool invocation nested inside the surrounding agent run, and provider-owned tool calls made from inside CodeAct continue to emit ordinary tool-invocation telemetry. Backend-specific resource metrics are optional extensions, not a required new top-level cross-SDK event model. +- Timeout, out-of-memory, backend crash, and similar sandbox failures are all execution failures of `execute_code` and should surface as structured error results rather than backend-specific public DTOs. Partial textual or file outputs may be returned only when the backend can report them unambiguously; callers must not rely on partial-output recovery as a portable guarantee. +- The provider-based structure preserves room for future pre-execution inspection and nested per-tool approvals if later experience shows they are needed. +- Concrete backend-specific providers may still use small SDK-local helpers or adapters internally, but that split is an implementation detail rather than a public API requirement. + +Detailed language-specific implementation notes are specified in: + +- [Python implementation](../features/code_act/python-implementation.md) +- [.NET implementation](../features/code_act/dotnet-implementation.md) + +### Minimal core hooks required by the optional package + +CodeAct remains optional at the package level, but the optional package depends on a small number of hooks that must live in the host SDK because the agent pipeline owns model invocation and per-run tool resolution. + +- Python depends on the existing `ContextProvider` lifecycle, `SessionContext.extend_instructions(...)`, `SessionContext.extend_tools(...)`, per-run runtime tool access via `SessionContext.options["tools"]`, and the shared `ApprovalMode` vocabulary used by `FunctionTool`. +- .NET depends on the existing `AIContextProvider` seam, agent/runtime support for applying providers before model invocation, and the existing chat-client or function-invocation seams that concrete implementations use to contribute `execute_code`. + +These hooks are backward-compatible because they only expose or forward per-run state that core already owns. Behavior changes only when a concrete CodeAct provider opts in and uses them. + +### Concrete provider implementation contract + +The design does not require a public abstract `CodeActContextProvider` base class, but it does require a stable implementation contract for concrete providers. + +- Concrete providers should expose a standard capability surface at construction time, with SDK-appropriate naming for: + - approval mode + - workspace root + - file mounts + - allowed outbound targets plus any per-target method or policy restrictions needed by the backend +- Separate public `filesystem_mode` / `network_mode` flags are not required by the cross-SDK contract. Filesystem access may be disabled implicitly until a workspace or file mounts are configured, and outbound network may be disabled implicitly until an allow-list or equivalent outbound policy entry is configured. +- Concrete providers should expose direct CRUD-style methods for managing the provider-owned CodeAct tool registry, file mounts, and outbound network allow-list configuration, rather than requiring callers to construct a separate runtime setup object. +- Concrete providers should implement their host SDK's provider lifecycle hooks to: + - build CodeAct instructions, + - add `execute_code`, + - snapshot the effective CodeAct tool registry and capability settings for the run, + - compute the effective approval requirement for `execute_code`, + - configure file access and network access for the backend, + - prepare or restore execution state, + - execute code, + - and translate backend output into framework-native content. +- Any internal abstract/helper surface shared by multiple concrete providers should standardize responsibilities for: + - instruction construction, + - file-access configuration, + - network-access configuration, + - environment preparation/restoration, + - code execution, + - and output-to-content conversion. +- Backend execution output should reuse existing framework-native content/message primitives rather than introducing backend-specific public result DTOs. + +## More Information + +### Related artifacts + +- Python implementation: [`docs/features/code_act/python-implementation.md`](../features/code_act/python-implementation.md) +- .NET implementation: [`docs/features/code_act/dotnet-implementation.md`](../features/code_act/dotnet-implementation.md) +- Python provider/session APIs: [`python/packages/core/agent_framework/_sessions.py`](../../python/packages/core/agent_framework/_sessions.py) +- Python function invocation loop: [`python/packages/core/agent_framework/_tools.py`](../../python/packages/core/agent_framework/_tools.py) +- .NET context provider abstraction: [`dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs`](../../dotnet/src/Microsoft.Agents.AI.Abstractions/AIContextProvider.cs) +- .NET agent integration for context providers: [`dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs`](../../dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgent.cs) +- Optional .NET chat-client provider decorator: [`dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs`](../../dotnet/src/Microsoft.Agents.AI/AIContextProviderDecorators/AIContextProviderChatClient.cs) +- .NET function invocation middleware seam: [`dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs`](../../dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgentBuilderExtensions.cs) + +### Related decisions + +- [0015-agent-run-context](0015-agent-run-context.md) +- [0016-python-context-middleware](0016-python-context-middleware.md) diff --git a/docs/features/code_act/dotnet-implementation.md b/docs/features/code_act/dotnet-implementation.md new file mode 100644 index 0000000000..5a2b51ae3a --- /dev/null +++ b/docs/features/code_act/dotnet-implementation.md @@ -0,0 +1,625 @@ +# CodeAct .NET implementation + +This document describes the .NET realization of the CodeAct design in +[`docs/decisions/0024-codeact-integration.md`](../../decisions/0024-codeact-integration.md). + +This document is intentionally focused on the .NET design and public API surface. +The initial public .NET type described here is `HyperlightCodeActProvider`. Future .NET backends, such as Monty, should follow the same conceptual model with their own concrete provider types rather than through a public abstract base class or a public executor parameter. + +## What is the goal of this feature? + +Goals: +- .NET developers can enable CodeAct through an `AIContextProvider`-based integration. +- Developers can configure a provider-owned CodeAct tool set that is separate from the agent's direct tool surface. +- Developers can use the same `execute_code` concept for both tool-enabled CodeAct and a standard code interpreter tool implementation. +- Developers can swap execution backends over time, starting with Hyperlight while keeping room for alternatives. +- Developers can configure execution capabilities such as workspace mounts and outbound network allow lists in a portable way. + +Success Metric: +- .NET samples exist for both a tool-enabled CodeAct mode and a standard interpreter mode. + +Implementation-free outcome: +- A .NET developer can attach a backend-specific CodeAct provider, choose which tools are available inside CodeAct, and configure execution capabilities without rewriting the function invocation loop or ChatClient pipeline. + +## What is the problem being solved? + +The cross-SDK problem statement and decision rationale live in the [ADR](../../decisions/0024-codeact-integration.md). The items below narrow that statement to .NET-specific design concerns: + +- Today, the easiest way to prototype CodeAct in .NET is to manually configure an `AIFunction` and wire instructions — this is fragile and requires understanding internal sandbox lifecycle details. +- There is no first-class .NET design that simultaneously covers Hyperlight-backed CodeAct now, future backend-specific providers, and both tool-enabled and interpreter modes. +- Sandbox capabilities such as mounted file access and outbound network access need a portable configuration model instead of ad hoc backend-specific wiring. +- Approval behavior needs to be explicit and configurable, mapping to .NET's existing `ApprovalRequiredAIFunction` wrapper mechanism. + +## API Changes + +### CodeAct contract + +#### Terminology + +- **CodeAct** is the primary term. +- `execute_code` is the model-facing tool name used by the initial .NET provider in this spec. +- Tool-enabled versus interpreter behavior is derived from the presence of CodeAct-managed tools, not from a separate public profile object. + +#### Provider-owned CodeAct tool registry + +A concrete .NET CodeAct provider owns the set of tools available through `call_tool(...)` inside CodeAct. + +Rules: +- Only tools explicitly configured on the concrete provider instance are available inside CodeAct. +- The provider must not infer its CodeAct-managed tool set from the agent's direct tool configuration (`ChatClientAgentOptions.Tools` or `AIContext.Tools`). +- Exclusive versus mixed behavior is achieved by where tools are configured, not by rewriting the agent's direct tool list. + +Implications: +- **CodeAct-only tool**: configured on the concrete CodeAct provider only. +- **Direct-only tool**: configured on the agent only. +- **Tool available both ways**: configured on both the agent and the concrete CodeAct provider. + +#### Managing tools and capabilities after provider construction + +There is no separate runtime setup object in the .NET design. CodeAct tools, file mounts, and outbound network allow-list state are managed directly on the provider through CRUD-style registry methods. + +Preferred pattern: +- `AddTools(params AIFunction[] tools) -> void` +- `GetTools() -> IReadOnlyList` +- `RemoveTools(params string[] names) -> void` +- `ClearTools() -> void` +- `AddFileMounts(params FileMount[] mounts) -> void` +- `GetFileMounts() -> IReadOnlyList` +- `RemoveFileMounts(params string[] mountPaths) -> void` +- `ClearFileMounts() -> void` +- `AddAllowedDomains(params AllowedDomain[] domains) -> void` +- `GetAllowedDomains() -> IReadOnlyList` +- `RemoveAllowedDomains(params string[] targets) -> void` +- `ClearAllowedDomains() -> void` + +Requirements: +- The provider-owned CodeAct tool registry is keyed by tool name (from `AIFunction.Name`). +- `AddTools(...)` adds new tools and replaces an existing provider-owned registration when the same tool name is added again. +- `GetTools()` returns the provider's current configured CodeAct tool registry. +- `RemoveTools(...)` removes provider-owned CodeAct tools by name. +- `ClearTools()` removes all provider-owned CodeAct tools. +- File mounts are keyed by sandbox mount path. +- `AddFileMounts(...)` adds new file mounts and replaces an existing mount when the same mount path is added again. +- `GetFileMounts()` returns the provider's current configured file mounts. +- `RemoveFileMounts(...)` removes file mounts by mount path. +- `ClearFileMounts()` removes all configured file mounts. +- Allowed domains are keyed by normalized target string. +- `AddAllowedDomains(...)` adds allow-list entries and replaces an existing entry when the same target is added again. +- `GetAllowedDomains()` returns the current outbound allow-list entries. +- `RemoveAllowedDomains(...)` removes allow-list entries by target. +- `ClearAllowedDomains()` removes all configured allow-list entries. +- Tool, file-mount, and network-allow-list mutations affect subsequent runs only; runs already in progress keep the snapshot captured at run start. +- The provider must snapshot its effective tool registry and capability state at the start of each run so concurrent execution remains deterministic. + +#### Approval model + +The initial .NET design follows the ADR's bundled approval decision and maps to the existing `ApprovalRequiredAIFunction` wrapper from `Microsoft.Extensions.AI.Abstractions`: + +- The provider exposes a default `ApprovalMode` for `execute_code` (enum: `CodeActApprovalMode.AlwaysRequire` / `CodeActApprovalMode.NeverRequire`). + +Effective `execute_code` approval is computed as follows: + +- If the provider default is `AlwaysRequire`, `execute_code` requires approval. +- If the provider default is `NeverRequire`, the provider evaluates the provider-owned CodeAct tool registry snapshot for that run. + - If every provider-owned CodeAct tool in that snapshot is not an `ApprovalRequiredAIFunction`, `execute_code` does not require approval. + - If any provider-owned CodeAct tool in that snapshot is an `ApprovalRequiredAIFunction`, `execute_code` requires approval, even if the generated code may not call that tool. +- When the effective approval resolves to `AlwaysRequire`, the generated `execute_code` function is wrapped in `ApprovalRequiredAIFunction` before being added to the `AIContext.Tools`. +- Provider-owned tool calls made through `call_tool(...)` during that execution run use the approval already determined for `execute_code`. +- Direct-only agent tools are excluded from this calculation. +- File and network capabilities do not create a separate runtime approval check in the initial model; configuring them on the provider is itself the approval for those capabilities. + +This is intentionally conservative and matches the shape of the existing .NET function-tool approval flow, where `ApprovalRequiredAIFunction` signals to the `ChatClientAgent` that user approval is needed before invocation. + +#### Shared execution flow + +On each run: +1. `ProvideAIContextAsync(...)` snapshots the current CodeAct-managed tool registry and capability settings. +2. Computes the effective approval requirement for `execute_code` from the provider default plus the snapshotted tool registry. +3. Builds provider-defined instructions. +4. Builds a run-scoped `execute_code` `AIFunction` from the snapshot (optionally wrapped in `ApprovalRequiredAIFunction`). +5. Returns an `AIContext` containing the instructions and `execute_code` tool. +6. When `execute_code` is invoked by the model, the run-scoped function creates or reuses an execution environment. +7. If the current provider mode exposes host tools, `call_tool(...)` is bound only to the provider-owned tool registry snapshot. +8. Code is executed and results converted to a JSON result string. + +Caching rules: +- The Hyperlight backend supports snapshots: the provider caches a reusable clean snapshot after the first sandbox initialization. +- No mutable per-run execution state may be shared across concurrent runs. +- In-memory interpreter state does not persist across separate `execute_code` calls. +- Configured workspace files, mounted files, and any writable artifact/output area are the supported persistence mechanism across calls when the backend exposes them. + +### .NET public API + +#### Core types + +```csharp +/// +/// Represents a host-to-sandbox file mount configuration. +/// +/// Absolute or relative path on the host filesystem. +/// Path inside the sandbox (e.g. "/input/data.csv"). +public sealed record FileMount(string HostPath, string MountPath); + +/// +/// Represents an outbound network allow-list entry. +/// +/// URL or domain (e.g. "https://api.github.com"). +/// +/// Optional HTTP methods to allow (e.g. ["GET", "POST"]). +/// Null allows all methods supported by the backend. +/// +public sealed record AllowedDomain(string Target, IReadOnlyList? Methods = null); + +/// +/// Controls the approval behavior for execute_code invocations. +/// +public enum CodeActApprovalMode +{ + /// execute_code always requires user approval. + AlwaysRequire, + + /// + /// Approval is derived from the provider-owned tool registry: + /// if any tool is an ApprovalRequiredAIFunction, execute_code requires approval. + /// + NeverRequire, +} +``` + +#### HyperlightCodeActProvider + +```csharp +/// +/// An AIContextProvider that enables CodeAct execution through the +/// Hyperlight sandbox backend. +/// +/// +/// +/// This provider injects an execute_code tool into the model-facing +/// tool surface and builds CodeAct guidance instructions. Guest code executed +/// through execute_code runs in an isolated Hyperlight sandbox with +/// snapshot/restore for clean state per invocation. +/// +/// +/// If no CodeAct-managed tools are configured, the provider uses +/// interpreter-style behavior. If one or more CodeAct-managed tools are +/// configured, the provider uses tool-enabled behavior and exposes +/// call_tool(...) inside the sandbox bound to the configured tools. +/// +/// +public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable +{ + /// + /// Initializes a new HyperlightCodeActProvider. + /// + /// Configuration options for the provider. + public HyperlightCodeActProvider(HyperlightCodeActProviderOptions options); + + // ----- Tool registry ----- + + /// Adds tools to the provider-owned CodeAct tool registry. + public void AddTools(params AIFunction[] tools); + + /// Returns the current CodeAct-managed tools. + public IReadOnlyList GetTools(); + + /// Removes tools by name from the CodeAct tool registry. + public void RemoveTools(params string[] names); + + /// Removes all CodeAct-managed tools. + public void ClearTools(); + + // ----- File mounts ----- + + /// Adds file mount configurations. + public void AddFileMounts(params FileMount[] mounts); + + /// Returns the current file mount configurations. + public IReadOnlyList GetFileMounts(); + + /// Removes file mounts by sandbox mount path. + public void RemoveFileMounts(params string[] mountPaths); + + /// Removes all file mount configurations. + public void ClearFileMounts(); + + // ----- Network allow-list ----- + + /// Adds outbound network allow-list entries. + public void AddAllowedDomains(params AllowedDomain[] domains); + + /// Returns the current outbound allow-list entries. + public IReadOnlyList GetAllowedDomains(); + + /// Removes allow-list entries by target. + public void RemoveAllowedDomains(params string[] targets); + + /// Removes all outbound allow-list entries. + public void ClearAllowedDomains(); + + // ----- Lifecycle ----- + + /// Releases the sandbox and all associated native resources. + public void Dispose(); +} +``` + +#### HyperlightCodeActProviderOptions + +```csharp +/// +/// Configuration options for . +/// +public sealed class HyperlightCodeActProviderOptions +{ + /// + /// The sandbox backend to use. Default is Wasm. + /// + public SandboxBackend Backend { get; set; } = SandboxBackend.Wasm; + + /// + /// Path to the guest module (.wasm or .aot file). + /// Required for the Wasm backend; not needed for JavaScript. + /// When null, the provider attempts to locate the default packaged + /// Python guest module. + /// + public string? ModulePath { get; set; } + + /// + /// Guest heap size. Accepts human-readable strings ("50Mi", "2Gi") + /// or raw byte values. Null uses the backend default. + /// + public string? HeapSize { get; set; } + + /// + /// Guest stack size. Accepts human-readable strings ("35Mi") + /// or raw byte values. Null uses the backend default. + /// + public string? StackSize { get; set; } + + /// + /// Initial set of CodeAct-managed tools available inside the sandbox. + /// + public IEnumerable? Tools { get; set; } + + /// + /// Default approval mode for the execute_code tool. + /// Default is . + /// + public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire; + + /// + /// Optional workspace root directory on the host. + /// When set, it is exposed as the sandbox's input directory. + /// + public string? WorkspaceRoot { get; set; } + + /// + /// Initial file mount configurations. + /// + public IEnumerable? FileMounts { get; set; } + + /// + /// Initial outbound network allow-list entries. + /// + public IEnumerable? AllowedDomains { get; set; } + + /// + /// State key used to store provider state in AgentSession.StateBag. + /// Defaults to "HyperlightCodeActProvider". Override when using + /// multiple provider instances on the same agent. + /// + public string? StateKey { get; set; } +} +``` + +#### Provider implementation contract + +The concrete provider plugs into the existing .NET `AIContextProvider` surface from `Microsoft.Agents.AI.Abstractions`. + +Required override: +- `ProvideAIContextAsync(InvokingContext, CancellationToken) -> ValueTask` + +`ProvideAIContextAsync(...)` is responsible for: +- snapshotting the current CodeAct-managed tool registry and capability settings for the run, +- computing the effective approval requirement for `execute_code` from the provider default and the snapshotted tool registry, +- building a short CodeAct guidance instruction string, +- building a run-scoped `execute_code` `AIFunction` from the snapshot, +- optionally wrapping it in `ApprovalRequiredAIFunction` when approval is required, +- and returning an `AIContext` with `Instructions` and `Tools` set. + +These steps run on every invocation rather than once at construction time because the provider supports CRUD mutations between runs, concurrent runs need independent snapshots, and the effective approval and instructions depend on the tool registry state captured at run start. + +The provider overrides `StateKeys` to return the configured `StateKey` from options, enabling multiple provider instances on the same agent without key collisions. + +Mutating the provider after `ProvideAIContextAsync(...)` has captured a run-scoped snapshot is allowed, but it affects subsequent runs only. Provider implementations synchronize state capture and CRUD operations so shared provider instances remain safe across concurrent runs. + +#### AIFunction-to-sandbox tool bridging + +The Hyperlight sandbox's `RegisterTool(name, Func)` accepts a synchronous JSON-in / JSON-out delegate. Provider-owned CodeAct tools are `AIFunction` instances that are async and cancellation-aware. + +Bridging strategy: +- At sandbox initialization time, the provider registers each CodeAct-managed tool with the sandbox using the raw JSON overload: `RegisterTool(name, Func)`. +- When the sandbox guest calls `call_tool("name", ...)`, the bridge delegate: + 1. Deserializes the JSON arguments. + 2. Invokes `AIFunction.InvokeAsync(...)` synchronously (via `GetAwaiter().GetResult()`) since the sandbox FFI callback is inherently synchronous. + 3. Serializes the result back to JSON. +- This sync-over-async bridge is a known pragmatic trade-off constrained by the Hyperlight FFI boundary. It is safe because: + - Sandbox execution already runs on the thread pool (via `Task.Run`). + - The FFI callback runs on a worker thread with no synchronization context. +- If the Hyperlight .NET SDK later adds async tool registration, the bridge should migrate to that. + +#### Runtime behavior + +- `ProvideAIContextAsync(...)` adds a short CodeAct guidance block through `AIContext.Instructions`. +- `ProvideAIContextAsync(...)` adds `execute_code` through `AIContext.Tools`. +- The detailed `call_tool(...)`, sandbox-tool, and capability guidance is carried by the `execute_code` function's `Description`. +- `execute_code` invokes the configured Hyperlight sandbox guest. +- If the current CodeAct tool registry snapshot is non-empty, the runtime injects `call_tool(...)` bound to the provider-owned tool registry. +- The provider does not inspect or mutate the agent's `ChatClientAgentOptions.Tools` or the incoming `AIContext.Tools` to determine its CodeAct tool set. +- The provider snapshots the current CodeAct tool registry and capability state at run start, so later registry and allow-list mutations only affect future runs. +- Interpreter versus tool-enabled behavior is derived from the presence of CodeAct-managed tools. +- `execute_code` is traced like a normal tool invocation within the surrounding agent run. + +#### Backend integration + +Initial public provider: +- `HyperlightCodeActProvider` + +Backend-specific notes: +- **Hyperlight** + - The provider internally creates a `SandboxBuilder` from the options and uses the `Sandbox` API from `HyperlightSandbox.Api`. + - The provider uses snapshot/restore to ensure clean execution state per `execute_code` invocation: a "warm" snapshot is taken after the first no-op initialization run, and restored before each subsequent execution. + - File access maps to Hyperlight Sandbox's `WithInputDir()` / `WithOutputDir()` / `WithTempOutput()` capability model. + - Network access is denied by default and is enabled through `Sandbox.AllowDomain(...)` per-target allow-list entries. + - Guest module resolution: if `ModulePath` is null for the Wasm backend, the provider attempts to locate a packaged Python guest module (equivalent to the Python SDK's `python_guest.path` resolution). + +#### Capability handling + +Capabilities are first-class `HyperlightCodeActProviderOptions` properties and provider-managed CRUD surfaces: +- `WorkspaceRoot` +- `FileMounts` +- `AllowedDomains` + +Enabling access means: +- Configuring `WorkspaceRoot` or any `FileMounts` enables the sandbox filesystem surface exposed through `/input` and `/output`. +- Leaving both `WorkspaceRoot` and `FileMounts` unset means no filesystem surface is configured. +- Adding any `AllowedDomains` entry enables outbound access only for the configured targets; leaving it empty means network access is disabled without a separate network mode flag. + +Backends may implement stricter semantics than these top-level settings. + +#### Execution output representation + +Backend execution output maps to a JSON result string returned from the `execute_code` `AIFunction`: + +```json +{ + "stdout": "Hello world\n", + "stderr": "", + "exit_code": 0, + "success": true +} +``` + +Execution failures should surface readable error text in the `stderr` field and a non-zero `exit_code`. Timeouts, out-of-memory conditions, backend crashes, and similar sandbox failures are all `execute_code` failures and should surface as structured error results. Partial textual or file outputs may be returned only when the backend can report them unambiguously. + +#### `execute_code` input contract + +```json +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Code to execute using the provider's configured backend/runtime behavior." + } + }, + "required": ["code"] +} +``` + +#### Thread safety and concurrency + +- All CRUD methods (`AddTools`, `RemoveTools`, `AddFileMounts`, etc.) are synchronized via an internal lock. +- `ProvideAIContextAsync(...)` acquires the lock to snapshot current state, then releases it before building the run-scoped function. The run-scoped function closes over the immutable snapshot, not mutable provider state. +- Concurrent `execute_code` invocations from different runs use independent sandbox instances or synchronized access to a shared sandbox with snapshot/restore. +- Workspace directories (`WorkspaceRoot`, `FileMounts`) are external shared state: concurrent runs against the same workspace can race on files. This is the user's responsibility to manage (e.g., by using per-run output directories or separate provider instances). + +### HyperlightExecuteCodeFunction + +The provider package also exports a standalone `HyperlightExecuteCodeFunction` for direct-tool scenarios where a provider lifecycle is not needed. This is the .NET equivalent of the Python `HyperlightExecuteCodeTool`. + +```csharp +/// +/// A standalone execute_code AIFunction backed by a Hyperlight sandbox. +/// Use this for manual/static wiring when the AIContextProvider lifecycle +/// is not needed. +/// +public sealed class HyperlightExecuteCodeFunction : IDisposable +{ + /// + /// Creates a new standalone code execution function. + /// + /// Configuration options. + public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions options); + + /// + /// Returns this as an AIFunction for direct registration on an agent. + /// When approval is required, the returned function is wrapped in + /// ApprovalRequiredAIFunction. + /// + public AIFunction AsAIFunction(); + + /// + /// Builds a CodeAct instruction string describing the available + /// tools and capabilities. + /// + /// + /// When false, the instructions include full tool descriptions + /// (for use when tools are only accessible through CodeAct). + /// When true, instructions are abbreviated (tools are already + /// visible to the model as direct tools). + /// + public string BuildInstructions(bool toolsVisibleToModel = false); + + /// Releases sandbox resources. + public void Dispose(); +} +``` + +### Internal implementation structure + +The provider and standalone function share internal helpers: + +``` +Microsoft.Agents.AI.Hyperlight/ +├── HyperlightCodeActProvider.cs // AIContextProvider implementation +├── HyperlightCodeActProviderOptions.cs // Options record +├── HyperlightExecuteCodeFunction.cs // Standalone AIFunction for manual wiring +├── FileMount.cs // File mount record +├── AllowedDomain.cs // Network allow-list record +├── CodeActApprovalMode.cs // Approval enum +├── Internal/ +│ ├── SandboxExecutor.cs // Manages sandbox lifecycle, snapshot/restore +│ ├── InstructionBuilder.cs // Builds CodeAct instruction strings +│ └── ToolBridge.cs // AIFunction ↔ Sandbox.RegisterTool adapter +``` + +`SandboxExecutor` encapsulates: +- Creating and configuring a `Sandbox` from options. +- Performing the initial no-op warm-up and snapshot. +- Registering bridged tools via `ToolBridge`. +- Restoring to the clean snapshot before each execution. +- Translating `ExecutionResult` to a JSON string. + +`InstructionBuilder` generates: +- A short CodeAct guidance block for `AIContext.Instructions`. +- A detailed `execute_code` description including `call_tool(...)` signatures and capability documentation. + +`ToolBridge` handles: +- Reflecting `AIFunction` metadata to build the sandbox tool registration. +- The sync-over-async invocation bridge. + +## E2E Code Samples + +### Tool-enabled CodeAct mode + +```csharp +var fetchDocs = AIFunctionFactory.Create(FetchDocs, name: "fetch_docs"); +var queryData = AIFunctionFactory.Create(QueryData, name: "query_data"); +var lookupUser = AIFunctionFactory.Create(LookupUser, name: "lookup_user"); + +var codeact = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions +{ + Tools = [fetchDocs, queryData], + WorkspaceRoot = "./workdir", + AllowedDomains = [new AllowedDomain("api.github.com", ["GET"])], +}); +codeact.AddTools(lookupUser); + +var sendEmail = AIFunctionFactory.Create(SendEmail, name: "send_email"); + +var agent = chatClient.AsAIAgent( + instructions: "You are a helpful assistant.", + options: new ChatClientAgentOptions + { + Tools = [sendEmail], // direct-only tool + AIContextProviders = [codeact], + }); + +await using var session = await agent.CreateSessionAsync(); +var response = await agent.InvokeAsync("Analyze the latest docs", session); +``` + +### Standard code interpreter mode + +```csharp +var codeact = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions +{ + WorkspaceRoot = "./data", +}); + +var agent = chatClient.AsAIAgent( + instructions: "You are a code interpreter.", + options: new ChatClientAgentOptions + { + AIContextProviders = [codeact], + }); +``` + +### Manual static wiring (no provider lifecycle) + +When the tool registry and capability configuration are fixed, the provider lifecycle can be skipped entirely. Build the `execute_code` function and instructions once and pass them directly to the agent: + +```csharp +using var executeCode = new HyperlightExecuteCodeFunction( + new HyperlightCodeActProviderOptions + { + Tools = [fetchDocs, queryData], + WorkspaceRoot = "./workdir", + AllowedDomains = [new AllowedDomain("api.github.com", ["GET"])], + }); + +var codeactInstructions = executeCode.BuildInstructions(toolsVisibleToModel: false); + +var agent = chatClient.AsAIAgent( + instructions: $"You are a helpful assistant.\n\n{codeactInstructions}", + options: new ChatClientAgentOptions + { + Tools = [sendEmail, executeCode.AsAIFunction()], + }); +``` + +### With approval required + +```csharp +var sensitiveAction = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create(DeleteRecords, name: "delete_records")); + +var codeact = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions +{ + Tools = [fetchDocs, sensitiveAction], // sensitiveAction triggers approval +}); + +// execute_code will be wrapped in ApprovalRequiredAIFunction because +// at least one managed tool (delete_records) requires approval. +var agent = chatClient.AsAIAgent( + instructions: "You are a helpful assistant.", + options: new ChatClientAgentOptions + { + AIContextProviders = [codeact], + }); +``` + +## Relationship to hyperlight-sandbox .NET SDK + +This design depends on the .NET SDK being added in [hyperlight-dev/hyperlight-sandbox#46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46). Key types consumed from that SDK: + +| hyperlight-sandbox type | Used for | +|---|---| +| `Sandbox` | Core sandbox lifecycle: `Run()`, `RegisterTool()`, `AllowDomain()`, `Snapshot()`, `Restore()` | +| `SandboxBuilder` | Fluent sandbox construction from provider options | +| `SandboxBackend` | Backend selection (Wasm, JavaScript) | +| `ExecutionResult` | Capturing stdout, stderr, exit code from guest execution | +| `SandboxSnapshot` | Checkpoint/restore for clean state per execution | + +The provider package (`Microsoft.Agents.AI.Hyperlight`) takes a NuGet dependency on `Hyperlight.HyperlightSandbox.Api` and `Microsoft.Extensions.AI.Abstractions`. It does **not** depend on `HyperlightSandbox.Extensions.AI` (`CodeExecutionTool`) — the provider implements its own sandbox lifecycle management with run-scoped snapshots to support concurrent invocations safely. + +## Package structure + +The CodeAct Hyperlight provider ships as an optional NuGet package: +- **Package**: `Microsoft.Agents.AI.Hyperlight` +- **Dependencies**: + - `Microsoft.Agents.AI.Abstractions` (for `AIContextProvider`, `AIContext`) + - `Microsoft.Extensions.AI.Abstractions` (for `AIFunction`, `ApprovalRequiredAIFunction`) + - `Hyperlight.HyperlightSandbox.Api` (for sandbox API) +- **Target framework**: `net8.0` + +This keeps CodeAct and its native sandbox dependencies optional — users who do not need CodeAct do not take on the Hyperlight installation and dependency footprint. + +## Open questions + +1. **Guest module distribution**: How should the default Python guest module (`.aot` file) be distributed for .NET consumers? Options include a separate NuGet package with native assets, a runtime download, or requiring users to build/provide their own. +2. **Async tool registration**: If the Hyperlight .NET SDK adds async tool callback support in a future release, the sync-over-async bridge should be replaced. This is tracked as a known technical debt item. +3. **Output file access**: The Hyperlight sandbox exposes `GetOutputFiles()` and `OutputPath` for retrieving files written by guest code. The initial design returns these as part of the JSON result. A future iteration could surface output files as framework-native content (e.g., `DataContent` or URI references). +4. **Multiple sandbox instances for concurrency**: The current design uses synchronized access to a single sandbox with snapshot/restore. An alternative pooling strategy (one sandbox per concurrent run) could improve throughput at the cost of memory. This is deferred to implementation time. diff --git a/docs/features/code_act/python-implementation.md b/docs/features/code_act/python-implementation.md new file mode 100644 index 0000000000..7f45190d33 --- /dev/null +++ b/docs/features/code_act/python-implementation.md @@ -0,0 +1,385 @@ +# CodeAct Python implementation + +This document describes the Python realization of the CodeAct design in +[`docs/decisions/0024-codeact-integration.md`](../../decisions/0024-codeact-integration.md). + +This document is intentionally focused on the Python design and public API surface. +The initial public Python type described here is `HyperlightCodeActProvider`. Future Python backends, such as Monty, should follow the same conceptual model with their own concrete provider types rather than through a public abstract base class or a public executor parameter. + +## What is the goal of this feature? + +Goals: +- Python developers can enable CodeAct through a `ContextProvider`-based integration. +- Developers can configure a provider-owned CodeAct tool set that is separate from the agent's direct `tools=` surface. +- Developers can use the same `execute_code` concept for both tool-enabled CodeAct and a standard code interpreter tool implementation. +- Developers can swap execution backends over time, starting with Hyperlight while keeping room for alternatives such as Pydantic's Monty. +- Developers can configure execution capabilities such as workspace mounts and outbound network allow lists in a portable way. + +Success Metric: +- Python samples exist for both a tool-enabled CodeAct mode and a standard interpreter mode. + +Implementation-free outcome: +- A Python developer can attach a backend-specific CodeAct provider, choose which tools are available inside CodeAct, and configure execution capabilities without rewriting the function invocation loop. + +## What is the problem being solved? + +The cross-SDK problem statement and decision rationale live in the [ADR](../../decisions/0024-codeact-integration.md). The items below narrow that statement to Python-specific design concerns: + +- Today, the easiest way to prototype CodeAct is to infer or reshape the agent's direct tool surface, which is fragile and hard to reason about. +- In Python, inferring a CodeAct tool surface from generic agent tool configuration is fragile and hard to reason about. +- There is no first-class Python design that simultaneously covers Hyperlight-backed CodeAct now, future backend-specific providers such as Monty, and both tool-enabled and interpreter modes. +- Sandbox capabilities such as mounted file access and outbound network access need a portable configuration model instead of ad hoc backend-specific wiring. +- Approval behavior needs to be explicit and configurable, especially when CodeAct and direct tool calling may both be available. + +## API Changes + +### CodeAct contract + +#### Terminology + +- **CodeAct** is the primary term. +- **Code mode**, **codemode**, and **programmatic tool calling** refer to the same concept in this document. +- `execute_code` is the model-facing tool name used by the initial Python providers in this spec. + +#### Provider-owned CodeAct tool registry + +A concrete Python CodeAct provider owns the set of tools available through `call_tool(...)` inside CodeAct. + +Rules: +- Only tools explicitly configured on the concrete provider instance are available inside CodeAct. +- The provider must not infer its CodeAct-managed tool set from the agent's direct `tools=` configuration. +- Exclusive versus mixed behavior is achieved by where tools are configured, not by rewriting the agent's direct tool list. + +Implications: +- **CodeAct-only tool**: configured on the concrete CodeAct provider only. +- **Direct-only tool**: configured on the agent only. +- **Tool available both ways**: configured on both the agent and the concrete CodeAct provider. + +#### Managing tools and capabilities after provider construction + +There is no separate runtime setup object in the Python design. CodeAct tools, file mounts, and outbound network allow-list state are managed directly on the provider through CRUD-style registry methods. + +Preferred pattern: +- `add_tools(...) -> None` +- `get_tools() -> Sequence[ToolTypes]` +- `remove_tool(...) -> None` +- `clear_tools() -> None` +- `add_file_mounts(...) -> None` +- `get_file_mounts() -> Sequence[FileMount]` +- `remove_file_mount(...) -> None` +- `clear_file_mounts() -> None` +- `add_allowed_domains(...) -> None` +- `get_allowed_domains() -> Sequence[AllowedDomain]` +- `remove_allowed_domain(...) -> None` +- `clear_allowed_domains() -> None` + +Requirements: +- The provider-owned CodeAct tool registry is keyed by tool name. +- `add_tools(...)` adds new tools and replaces an existing provider-owned registration when the same tool name is added again. +- `get_tools()` returns the provider's current configured CodeAct tool registry. +- `remove_tool(...)` removes provider-owned CodeAct tools by name. +- `clear_tools()` removes all provider-owned CodeAct tools. +- File mounts are keyed by sandbox mount path. +- `add_file_mounts(...)` adds new file mounts and replaces an existing mount when the same mount path is added again. +- `get_file_mounts()` returns the provider's current configured file mounts. +- `remove_file_mount(...)` removes file mounts by mount path. +- `clear_file_mounts()` removes all configured file mounts. +- Allowed domains are keyed by normalized target string. +- `add_allowed_domains(...)` adds allow-list entries and replaces an existing entry when the same target is added again. +- `get_allowed_domains()` returns the current outbound allow-list entries. +- `remove_allowed_domain(...)` removes allow-list entries by target. +- `clear_allowed_domains()` removes all configured allow-list entries. +- Tool, file-mount, and network-allow-list mutations affect subsequent runs only; runs already in progress keep the snapshot captured at run start. +- The provider must snapshot its effective tool registry and capability state at the start of each run so concurrent execution remains deterministic. + +#### Approval model + +The initial Python design follows the ADR's initial approval decision and reuses the existing tool approval vocabulary from `agent_framework._tools`: + +- `approval_mode="always_require"` +- `approval_mode="never_require"` + +The provider exposes a default `approval_mode` for `execute_code`. + +Effective `execute_code` approval is computed as follows: + +- If the provider default is `always_require`, `execute_code` requires approval. +- If the provider default is `never_require`, the provider evaluates the provider-owned CodeAct tool registry snapshot for that run. +- If every provider-owned CodeAct tool in that snapshot is `never_require`, `execute_code` is `never_require`. +- If any provider-owned CodeAct tool in that snapshot is `always_require`, `execute_code` is `always_require`, even if the generated code may not call that tool. +- Provider-owned tool calls made through `call_tool(...)` during that execution run use the approval already determined for `execute_code`. +- Direct-only agent tools are excluded from this calculation. +- File and network capabilities do not create a separate runtime approval check in the initial model; configuring them on the provider, including adding file mounts or outbound network allow-list entries, is itself the approval for those capabilities. + +This is intentionally conservative and matches the shape of the current function-tool approval flow, where `FunctionTool` uses `always_require` / `never_require` and the auto-invocation loop escalates the whole batch if any called tool requires approval. + +If one sensitive provider-owned tool causes `execute_code` to require approval more often than desired, the mitigation is to keep that tool direct-only or expose it through a different CodeAct provider/tool surface. The initial model does not try to infer whether generated code will actually call that tool before approval. + +If the framework later standardizes pre-execution inspection or nested per-tool approvals, the Python provider surface can grow to expose that explicitly. The initial design does not assume that those extra modes are required. + +#### Shared execution flow + +On each run: +1. Resolve the provider's backend/runtime behavior, capabilities, provider default `approval_mode`, and provider-owned tool registry. +2. Compute the effective approval requirement for `execute_code` from the provider default plus the provider-owned tool registry snapshot. +3. Build provider-defined instructions. +4. Add `execute_code` to the model-facing tool surface. +5. Invoke the underlying model. +6. When `execute_code` is called, create or reuse an execution environment keyed by provider type, backend setup identity, capability configuration, and provider-owned tool signature. +7. If the current provider mode exposes host tools, expose `call_tool(...)` bound only to the provider-owned tool registry. +8. Execute code and convert results to framework-native content objects. + +Caching rules: +- Backends that support snapshots may cache a reusable clean snapshot. +- Backends that do not support snapshots may still cache warm initialization artifacts. +- No mutable per-run execution state may be shared across concurrent runs. +- In-memory interpreter state does not persist across separate `execute_code` calls. +- Configured workspace files, mounted files, and any writable artifact/output area are the supported persistence mechanism across calls when the backend exposes them. + +### Python public API + +#### Core types + +```python +class FileMount(NamedTuple): + host_path: str | Path + mount_path: str + +FileMountInput = str | tuple[str | Path, str] | FileMount + + +class AllowedDomain(NamedTuple): + target: str + methods: tuple[str, ...] | None = None + + +AllowedDomainInput = str | tuple[str, str | Sequence[str]] | AllowedDomain + + +class HyperlightCodeActProvider(ContextProvider): + def __init__( + self, + source_id: str = "hyperlight_codeact", + *, + backend: str = "wasm", + module: str | None = "python_guest.path", + module_path: str | None = None, + tools: ToolTypes | None = None, + approval_mode: Literal["always_require", "never_require"] = "never_require", + workspace_root: Path | None = None, + file_mounts: Sequence[FileMountInput] = (), + allowed_domains: Sequence[AllowedDomainInput] = (), + ) -> None: ... + + def add_tools(self, tools: ToolTypes | Sequence[ToolTypes]) -> None: ... + def get_tools(self) -> Sequence[ToolTypes]: ... + def remove_tool(self, name: str) -> None: ... + def clear_tools(self) -> None: ... + def add_file_mounts(self, mounts: FileMountInput | Sequence[FileMountInput]) -> None: ... + def get_file_mounts(self) -> Sequence[FileMount]: ... + def remove_file_mount(self, mount_path: str) -> None: ... + def clear_file_mounts(self) -> None: ... + def add_allowed_domains(self, domains: AllowedDomainInput | Sequence[AllowedDomainInput]) -> None: ... + def get_allowed_domains(self) -> Sequence[AllowedDomain]: ... + def remove_allowed_domain(self, domain: str) -> None: ... + def clear_allowed_domains(self) -> None: ... +``` + +`file_mounts` accepts three equivalent input forms: +- `"data/report.csv"` uses the same relative path on the host and in the sandbox. +- `("fixtures/users.json", "data/users.json")` or `(Path("fixtures/users.json"), "data/users.json")` uses distinct host and sandbox paths. +- `FileMount(Path("fixtures/users.json"), "data/users.json")` is the named-tuple form of the explicit pair. + +`allowed_domains` accepts three equivalent input forms: +- `"github.com"` allows that target with all backend-supported methods. +- `("github.com", "GET")` or `("github.com", ["GET", "HEAD"])` uses an explicit per-target method list. +- `AllowedDomain("github.com", ("GET", "HEAD"))` is the named-tuple form of the explicit entry. + +No public abstract `CodeActContextProvider` base or public `executor=` parameter is required for the initial Python API. + +The initial alpha package also exports a standalone `HyperlightExecuteCodeTool` +for direct-tool scenarios where a provider is not needed. That standalone tool +should advertise `call_tool(...)`, the registered sandbox tools, and capability +state through its own `description` rather than requiring separate agent +instructions. + +Provider modes: +- If no CodeAct-managed tools are configured, `HyperlightCodeActProvider` uses interpreter-style behavior. +- If one or more CodeAct-managed tools are configured, `HyperlightCodeActProvider` uses tool-enabled behavior. + +#### Python provider implementation contract + +The concrete provider plugs into the existing Python `ContextProvider` surface from `agent_framework._sessions`. + +The Hyperlight package also depends on a small set of core hooks that must remain available from `agent-framework-core`: +- `ContextProvider.before_run(...)` +- `SessionContext.extend_instructions(...)` +- `SessionContext.extend_tools(...)` +- per-run runtime tool access via `SessionContext.options["tools"]` +- the shared `ApprovalMode` vocabulary used by `FunctionTool` + +Required lifecycle hook: +- `before_run(*, agent, session, context, state) -> None` + +Optional lifecycle hook: +- `after_run(*, agent, session, context, state) -> None` + +`before_run(...)` is responsible for: +- snapshotting the current CodeAct-managed tool registry and capability settings for the run, +- computing the effective approval requirement for `execute_code` from the provider default and the snapshotted tool registry, +- adding a short CodeAct guidance block, +- adding `execute_code` to the run through `SessionContext.extend_tools(...)`, +- and wiring any backend-specific execution state needed for the run. + +These steps run on every invocation rather than once at construction time because the provider supports CRUD mutations between runs, concurrent runs need independent snapshots, and the effective approval and instructions depend on the tool registry state captured at run start. When the tool registry and capability configuration are fixed for the lifetime of the agent, the manual wiring pattern (see `codeact_manual_wiring.py`) can be used instead, which passes the tool and instructions directly to the `Agent` constructor and avoids the per-run provider lifecycle entirely. + +If the provider stores anything in `state`, that value must stay JSON-serializable. + +Mutating the provider after `before_run(...)` has captured a run-scoped snapshot is allowed, but it affects subsequent runs only. Provider implementations should synchronize state capture and CRUD operations so shared provider instances remain safe across concurrent runs. + +`after_run(...)` is responsible for any backend-specific cleanup or post-processing that must happen after the model invocation completes. + +If shared internal helpers are introduced later for multiple concrete providers, they should standardize responsibilities for: +- building instructions, +- computing effective approval, +- configuring file access, +- configuring network access, +- preparing or restoring execution state, +- executing code, +- and converting backend output into framework-native `Content`. + +#### Runtime behavior + +- `before_run(...)` adds a short CodeAct guidance block through `SessionContext.extend_instructions(...)`. +- `before_run(...)` adds `execute_code` through `SessionContext.extend_tools(...)`. +- The detailed `call_tool(...)`, sandbox-tool, and capability guidance is carried by `execute_code.description`. +- `execute_code` invokes the configured Hyperlight sandbox guest. +- If the current CodeAct tool registry is non-empty, the runtime injects `call_tool(...)` bound to the provider-owned tool registry. +- The provider does not inspect or mutate `Agent.default_options["tools"]` or `context.options["tools"]` to determine its CodeAct tool set. +- The provider snapshots the current CodeAct tool registry and capability state at run start, so later registry and allow-list mutations only affect future runs. +- Interpreter versus tool-enabled behavior is derived from the concrete provider and the presence of CodeAct-managed tools, not from a separate public profile object. +- `execute_code` should be traced like a normal tool invocation within the surrounding agent run, and provider-owned tool calls executed through `call_tool(...)` should continue to emit ordinary tool invocation telemetry. + +#### Backend integration + +Initial public provider: +- `HyperlightCodeActProvider` + +Backend-specific notes: +- **Hyperlight** + - Provider construction needs a guest artifact via `module`, which may be a packaged guest module name or a path to a compiled guest artifact. + - File access maps naturally to Hyperlight Sandbox's read-only `/input` and writable `/output` capability model. + - Network access is denied by default and is enabled through per-target allow-list entries. +- **Monty** + - A future `MontyCodeActProvider` should be a separate public type rather than a `HyperlightCodeActProvider` mode. + - Monty does not expose built-in filesystem or network access directly inside the interpreter. + - File and URL access are mediated through host-provided external functions, so a Monty provider would need to translate provider settings into virtual files and allow-checked callbacks. + - Monty setup may also include backend-specific inputs such as `script_name`, optional type-check stubs, or restored snapshots. + +#### Capability handling + +Capabilities are first-class `HyperlightCodeActProvider` init parameters and provider-managed CRUD surfaces: +- `workspace_root` +- `file_mounts` +- `allowed_domains` + +Concrete providers should normalize these settings internally. Hyperlight can map them directly to sandbox capabilities, while Monty must enforce them through host-mediated file and network functions and may apply stricter URL-level checks than the public provider surface expresses. + +Expected management split: +- `workspace_root` remains a direct configuration value on the provider, +- file mounts are managed through provider CRUD methods, +- outbound allow-list entries are managed through provider CRUD methods. + +Enabling access means: +- Configuring `workspace_root` or any `file_mounts` enables the sandbox filesystem surface exposed through `/input` and `/output`. +- Leaving both `workspace_root` and `file_mounts` unset means no filesystem surface is configured. +- Adding any `allowed_domains` entry enables outbound access only for the configured targets; leaving it empty means network access is disabled without a separate `network_mode` flag. +- A string target allows all backend-supported methods for that target; an explicit tuple or `AllowedDomain` entry narrows the methods for that target. + +Backends may implement stricter semantics than these top-level settings. For example, Hyperlight naturally maps file access to `/input` and `/output`, while Monty would enforce equivalent policy through host-provided callbacks rather than direct interpreter I/O. + +#### Execution output representation + +Backend execution output should be translated into existing AF `Content` values rather than a custom `CodeActExecutionResult` type. + +Use the existing content model from `agent_framework._types`, for example: +- `Content.from_code_interpreter_tool_result(outputs=[...])` to surface the overall result of sandboxed code execution, +- `Content.from_text(...)` for plain textual output, +- `Content.from_data(...)` or `Content.from_uri(...)` for generated files or binary artifacts, +- `Content.from_error(...)` for execution failures, +- and `Content.from_function_result(..., result=list[Content])` when surfacing the final result of `execute_code` through the normal tool result path. + +#### `execute_code` input contract + +```json +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Code to execute using the provider's configured backend/runtime behavior." + } + }, + "required": ["code"] +} +``` + +Execution failures should surface readable error text and structured error `Content`, not a custom backend result object. + +Timeouts, out-of-memory conditions, backend crashes, and similar sandbox failures are all `execute_code` failures and should surface as structured error content. Partial textual or file outputs may be returned only when the backend can report them unambiguously; callers should not rely on partial-output recovery as a portable contract. + +## E2E Code Samples + +### Tool-enabled CodeAct mode + +```python +codeact = HyperlightCodeActProvider( + tools=[fetch_docs, query_data], + workspace_root="./workdir", + allowed_domains=[("api.github.com", "GET")], +) +codeact.add_tools([lookup_user]) + +agent = Agent( + client=client, + name="assistant", + tools=[send_email], # direct-only tool + context_providers=[codeact], +) +``` + +### Standard code interpreter mode + +```python +codeact = HyperlightCodeActProvider( + workspace_root="./data", +) + +agent = Agent( + client=client, + name="interpreter", + context_providers=[codeact], +) +``` + +### Manual static wiring (no per-run provider lifecycle) + +When the tool registry and capability configuration are fixed, the provider lifecycle can be skipped entirely. Build the `execute_code` tool and instructions once and pass them directly to the agent: + +```python +execute_code = HyperlightExecuteCodeTool( + tools=[fetch_docs, query_data], + workspace_root="./workdir", + allowed_domains=[("api.github.com", "GET")], + approval_mode="never_require", +) + +codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False) + +agent = Agent( + client=client, + name="assistant", + instructions=f"You are a helpful assistant.\n\n{codeact_instructions}", + tools=[send_email, execute_code], +) +``` diff --git a/python/.cspell.json b/python/.cspell.json index a26cc7fed7..b72fa96cf5 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -30,6 +30,7 @@ "azuredocs", "azurefunctions", "boto", + "codeact", "contentvector", "contoso", "datamodel", @@ -45,6 +46,7 @@ "hnsw", "httpx", "huggingface", + "hyperlight", "Instrumentor", "logit", "logprobs", diff --git a/python/PACKAGE_STATUS.md b/python/PACKAGE_STATUS.md index e6b5f403ce..661cebe53a 100644 --- a/python/PACKAGE_STATUS.md +++ b/python/PACKAGE_STATUS.md @@ -33,6 +33,7 @@ Status is grouped into these buckets: | `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` | | `agent-framework-gemini` | `python/packages/gemini` | `alpha` | | `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` | +| `agent-framework-hyperlight` | `python/packages/hyperlight` | `alpha` | | `agent-framework-lab` | `python/packages/lab` | `beta` | | `agent-framework-mem0` | `python/packages/mem0` | `beta` | | `agent-framework-ollama` | `python/packages/ollama` | `beta` | diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 47eefe8da9..75b21d9932 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -89,6 +89,7 @@ logger = logging.getLogger("agent_framework") DEFAULT_MAX_ITERATIONS: Final[int] = 40 DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST: Final[int] = 3 SHELL_TOOL_KIND_VALUE: Final[str] = "shell" +ApprovalMode: TypeAlias = Literal["always_require", "never_require"] ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -270,7 +271,7 @@ class FunctionTool(SerializationMixin): *, name: str, description: str = "", - approval_mode: Literal["always_require", "never_require"] | None = None, + approval_mode: ApprovalMode | None = None, kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1033,7 +1034,7 @@ def tool( name: str | None = None, description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, + approval_mode: ApprovalMode | None = None, kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1049,7 +1050,7 @@ def tool( name: str | None = None, description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, + approval_mode: ApprovalMode | None = None, kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, @@ -1064,7 +1065,7 @@ def tool( name: str | None = None, description: str | None = None, schema: type[BaseModel] | Mapping[str, Any] | None = None, - approval_mode: Literal["always_require", "never_require"] | None = None, + approval_mode: ApprovalMode | None = None, kind: str | None = None, max_invocations: int | None = None, max_invocation_exceptions: int | None = None, diff --git a/python/packages/hyperlight/LICENSE b/python/packages/hyperlight/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/hyperlight/LICENSE @@ -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 diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md new file mode 100644 index 0000000000..1b1bc1e0ce --- /dev/null +++ b/python/packages/hyperlight/README.md @@ -0,0 +1,132 @@ +# agent-framework-hyperlight + +Alpha Hyperlight-backed CodeAct integrations for Microsoft Agent Framework. + +## Installation + +```bash +pip install agent-framework-hyperlight --pre +``` + +This package depends on `hyperlight-sandbox`, the packaged Python guest, and the +Wasm backend package on supported platforms. If the backend is not published for +your current platform yet, `execute_code` will fail at runtime when it tries to +create the sandbox. + +## Quick start + +### Context provider (recommended) + +Use `HyperlightCodeActProvider` to automatically inject the `execute_code` tool +and CodeAct instructions into every agent run. Tools registered on the provider +are available inside the sandbox via `call_tool(...)` but are **not** exposed as +direct agent tools. + +```python +from agent_framework import Agent, tool +from agent_framework_hyperlight import HyperlightCodeActProvider + +@tool +def compute(operation: str, a: float, b: float) -> float: + """Perform a math operation.""" + ops = {"add": a + b, "subtract": a - b, "multiply": a * b, "divide": a / b} + return ops[operation] + +codeact = HyperlightCodeActProvider( + tools=[compute], + approval_mode="never_require", +) + +agent = Agent( + client=client, + name="CodeActAgent", + instructions="You are a helpful assistant.", + context_providers=[codeact], +) + +result = await agent.run("Multiply 6 by 7 using execute_code.") +``` + +### Standalone tool + +Use `HyperlightExecuteCodeTool` directly when you want full control over how the +tool is added to the agent. This is useful when mixing sandbox tools with +direct-only tools on the same agent. + +```python +from agent_framework import Agent, tool +from agent_framework_hyperlight import HyperlightExecuteCodeTool + +@tool +def send_email(to: str, subject: str, body: str) -> str: + """Send an email (direct-only, not available inside the sandbox).""" + return f"Email sent to {to}" + +execute_code = HyperlightExecuteCodeTool( + tools=[compute], + approval_mode="never_require", +) + +agent = Agent( + client=client, + name="MixedToolsAgent", + instructions="You are a helpful assistant.", + tools=[send_email, execute_code], +) +``` + +### Manual static wiring + +For fixed configurations where provider lifecycle overhead is unnecessary, build +the CodeAct instructions once and pass them to the agent at construction time: + +```python +execute_code = HyperlightExecuteCodeTool( + tools=[compute], + approval_mode="never_require", +) + +codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False) + +agent = Agent( + client=client, + name="StaticWiringAgent", + instructions=f"You are a helpful assistant.\n\n{codeact_instructions}", + tools=[execute_code], +) +``` + +### File mounts and network access + +Mount host directories into the sandbox and allow outbound HTTP to specific +domains: + +```python +from agent_framework_hyperlight import HyperlightCodeActProvider, FileMount + +codeact = HyperlightCodeActProvider( + tools=[compute], + file_mounts=[ + "/host/data", # shorthand — same path in sandbox + ("/host/models", "/sandbox/models"), # explicit host → sandbox mapping + FileMount("/host/config", "/sandbox/config"), # named tuple + ], + allowed_domains=[ + "api.github.com", # all methods + ("internal.api.example.com", "GET"), # GET only + ], +) +``` + +## Notes + +- This package is intentionally separate from `agent-framework-core` so CodeAct + usage and installation remain optional. +- Alpha-package samples live under `packages/hyperlight/samples/`. +- `file_mounts` accepts a single string shorthand, an explicit `(host_path, + mount_path)` pair, or a `FileMount` named tuple. The host-side path in the + explicit forms may be a `str` or `Path`. Use the explicit two-value form when + the host path differs from the sandbox path. +- `allowed_domains` accepts a single string target such as `"github.com"` to + allow all backend-supported methods, an explicit `(target, method_or_methods)` + tuple such as `("github.com", "GET")`, or an `AllowedDomain` named tuple. diff --git a/python/packages/hyperlight/agent_framework_hyperlight/__init__.py b/python/packages/hyperlight/agent_framework_hyperlight/__init__.py new file mode 100644 index 0000000000..511252d0df --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/__init__.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import importlib.metadata + +from ._execute_code_tool import HyperlightExecuteCodeTool +from ._provider import HyperlightCodeActProvider +from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountInput + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = [ + "AllowedDomain", + "AllowedDomainInput", + "FileMount", + "FileMountInput", + "HyperlightCodeActProvider", + "HyperlightExecuteCodeTool", + "__version__", +] diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py new file mode 100644 index 0000000000..b15a2569c1 --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -0,0 +1,860 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import ast +import asyncio +import copy +import mimetypes +import shutil +import threading +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from tempfile import TemporaryDirectory +from typing import Annotated, Any, Protocol, TypeGuard, cast +from urllib.parse import urlparse + +from agent_framework import Content, FunctionTool +from agent_framework._tools import ApprovalMode, normalize_tools +from pydantic import BaseModel, Field + +from ._instructions import build_codeact_instructions, build_execute_code_description +from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput + +DEFAULT_HYPERLIGHT_BACKEND = "wasm" +DEFAULT_HYPERLIGHT_MODULE = "python_guest.path" +EXECUTE_CODE_INPUT_DESCRIPTION = "Python code to execute in an isolated Hyperlight sandbox." +OUTPUT_FILE_RETRY_ATTEMPTS = 10 +OUTPUT_FILE_RETRY_DELAY_SECONDS = 0.1 + + +class _ExecuteCodeInput(BaseModel): + code: Annotated[str, Field(description=EXECUTE_CODE_INPUT_DESCRIPTION)] + + +@dataclass(frozen=True, slots=True) +class _StoredFileMount: + host_path: Path + mount_path: str + + +@dataclass(frozen=True, slots=True) +class _NormalizedFileMount: + host_path: Path + mount_path: str + path_signature: tuple[tuple[str, int, int], ...] + + +@dataclass(frozen=True, slots=True) +class _RunConfig: + backend: str + module: str | None + module_path: str | None + approval_mode: ApprovalMode + tools: tuple[FunctionTool, ...] + workspace_root: Path | None + workspace_signature: tuple[tuple[str, int, int], ...] + file_mounts: tuple[_NormalizedFileMount, ...] + allowed_domains: tuple[AllowedDomain, ...] + + @property + def mounted_paths(self) -> tuple[str, ...]: + return tuple(_display_mount_path(mount.mount_path) for mount in self.file_mounts) + + @property + def filesystem_enabled(self) -> bool: + return self.workspace_root is not None or bool(self.file_mounts) + + def cache_key(self) -> tuple[Any, ...]: + return ( + self.backend, + self.module, + self.module_path, + self.approval_mode, + tuple((tool_obj.name, id(tool_obj)) for tool_obj in self.tools), + str(self.workspace_root) if self.workspace_root is not None else None, + self.workspace_signature, + tuple((mount.mount_path, str(mount.host_path), mount.path_signature) for mount in self.file_mounts), + tuple((allowed_domain.target, allowed_domain.methods) for allowed_domain in self.allowed_domains), + ) + + +class SandboxRuntime(Protocol): + def execute(self, *, config: _RunConfig, code: str) -> list[Content]: ... + + +@dataclass +class _SandboxEntry: + sandbox: Any + snapshot: Any + input_dir: TemporaryDirectory[str] | None + output_dir: TemporaryDirectory[str] | None + lock: threading.RLock + + +def _load_sandbox_class() -> type[Any]: + try: + from hyperlight_sandbox import Sandbox + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "Hyperlight support requires `hyperlight-sandbox`, `hyperlight-sandbox-python-guest`, " + "and a compatible backend package such as `hyperlight-sandbox-backend-wasm`." + ) from exc + + return Sandbox + + +def _passthrough_result_parser(result: Any) -> str: + return repr(result) + + +def _collect_tools(*tool_groups: Any) -> list[FunctionTool]: + tools_by_name: dict[str, FunctionTool] = {} + + for tool_group in tool_groups: + normalized_group = normalize_tools(tool_group) + for tool_obj in normalized_group: + if not isinstance(tool_obj, FunctionTool): + continue + if tool_obj.name == "execute_code": + continue + tools_by_name.pop(tool_obj.name, None) + tools_by_name[tool_obj.name] = tool_obj + + return list(tools_by_name.values()) + + +def _resolve_execute_code_approval_mode( + *, + base_approval_mode: ApprovalMode, + tools: Sequence[FunctionTool], +) -> ApprovalMode: + if base_approval_mode == "always_require": + return "always_require" + + if any(tool_obj.approval_mode == "always_require" for tool_obj in tools): + return "always_require" + + return "never_require" + + +def _resolve_existing_path(value: str | Path) -> Path: + return Path(value).expanduser().resolve(strict=True) + + +def _resolve_workspace_root(value: str | Path | None) -> Path | None: + if value is None: + return None + + resolved_path = _resolve_existing_path(value) + if not resolved_path.is_dir(): + raise ValueError("workspace_root must point to an existing directory.") + return resolved_path + + +def _is_file_mount_pair(value: Any) -> TypeGuard[FileMount | tuple[FileMountHostPath, str]]: + if not isinstance(value, tuple): + return False + + value_tuple = cast(tuple[object, ...], value) + if len(value_tuple) != 2: + return False + + host_path, mount_path = value_tuple + return isinstance(host_path, (str, Path)) and isinstance(mount_path, str) + + +def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount: + host_path: FileMountHostPath + mount_path: str + if isinstance(file_mount, str): + host_path = file_mount + mount_path = file_mount + else: + host_path = file_mount[0] + mount_path = file_mount[1] + + return _StoredFileMount( + host_path=_resolve_existing_path(host_path), + mount_path=_normalize_mount_path(mount_path), + ) + + +def _normalize_domain(target: str) -> str: + candidate = target.strip() + if not candidate: + raise ValueError("Allowed domain entries must not be empty.") + + parsed = urlparse(candidate if "://" in candidate else f"//{candidate}") + normalized = (parsed.netloc or parsed.path).strip().rstrip("/") + if not normalized: + raise ValueError(f"Could not normalize allowed domain entry: {target!r}.") + return normalized.lower() + + +def _normalize_http_method(method: str) -> str: + normalized = method.strip().upper() + if not normalized: + raise ValueError("HTTP method entries must not be empty.") + return normalized + + +def _normalize_http_methods(methods: str | Sequence[str] | None) -> tuple[str, ...] | None: + if methods is None: + return None + + normalized_methods = ( + {_normalize_http_method(methods)} + if isinstance(methods, str) + else {_normalize_http_method(method) for method in methods} + ) + if not normalized_methods: + raise ValueError("Allowed domain methods must not be empty when provided.") + return tuple(sorted(normalized_methods)) + + +def _is_allowed_domain_pair(value: Any) -> TypeGuard[tuple[str, str | Sequence[str]]]: + if not isinstance(value, tuple) or isinstance(value, AllowedDomain): + return False + + value_tuple = cast(tuple[object, ...], value) + if len(value_tuple) != 2: + return False + + target, methods = value_tuple + if not isinstance(target, str): + return False + if isinstance(methods, str): + return True + return isinstance(methods, Sequence) + + +def _normalize_allowed_domain_input(allowed_domain: AllowedDomainInput) -> AllowedDomain: + if isinstance(allowed_domain, str): + return AllowedDomain(target=_normalize_domain(allowed_domain), methods=None) + + if isinstance(allowed_domain, AllowedDomain): + return AllowedDomain( + target=_normalize_domain(allowed_domain.target), + methods=_normalize_http_methods(allowed_domain.methods), + ) + + target, methods = allowed_domain + return AllowedDomain( + target=_normalize_domain(target), + methods=_normalize_http_methods(methods), + ) + + +def _allowed_domain_registration_targets(*, target: str, expand_missing_scheme: bool) -> tuple[str, ...]: + if not expand_missing_scheme or "://" in target: + return (target,) + return (f"http://{target}", f"https://{target}") + + +def _should_retry_allowed_domain_registration( + *, + error: RuntimeError, + allowed_domains: Sequence[AllowedDomain], +) -> bool: + message = str(error).lower() + return "invalid url for network permission" in message and any( + "://" not in domain.target for domain in allowed_domains + ) + + +def _normalize_mount_path(mount_path: str) -> str: + raw_path = mount_path.strip().replace("\\", "/") + if not raw_path: + raise ValueError("mount_path must not be empty.") + + pure_path = PurePosixPath(raw_path) + parts = [part for part in pure_path.parts if part not in {"", "/", "."}] + if parts and parts[0] == "input": + parts = parts[1:] + if any(part == ".." for part in parts): + raise ValueError("mount_path must stay within /input.") + if not parts: + raise ValueError("mount_path must point to a concrete path under /input.") + return "/".join(parts) + + +def _display_mount_path(mount_path: str) -> str: + return f"/input/{mount_path}" + + +def _path_tree_signature(path: Path) -> tuple[tuple[str, int, int], ...]: + if path.is_file(): + stat = path.stat() + return ((path.name, int(stat.st_size), int(stat.st_mtime_ns)),) + + entries: list[tuple[str, int, int]] = [] + for candidate in sorted(path.rglob("*"), key=lambda value: value.as_posix()): + try: + stat = candidate.stat() + except FileNotFoundError: + continue + relative_path = candidate.relative_to(path).as_posix() + size = int(stat.st_size) if candidate.is_file() else 0 + entries.append((relative_path, size, int(stat.st_mtime_ns))) + return tuple(entries) + + +def _copy_path(source: Path, destination: Path) -> None: + if source.is_dir(): + destination.mkdir(parents=True, exist_ok=True) + for child in sorted(source.iterdir(), key=lambda value: value.name): + _copy_path(child, destination / child.name) + return + + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def _populate_input_dir(*, config: _RunConfig, input_root: Path) -> None: + if config.workspace_root is not None: + for child in sorted(config.workspace_root.iterdir(), key=lambda value: value.name): + _copy_path(child, input_root / child.name) + + for mount in config.file_mounts: + _copy_path(mount.host_path, input_root / mount.mount_path) + + +def _create_file_content(file_path: Path, *, relative_path: str) -> Content: + media_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream" + return Content.from_data( + data=file_path.read_bytes(), + media_type=media_type, + additional_properties={"path": f"/output/{relative_path}"}, + ) + + +def _normalize_output_relative_path(*, output_file: object, root: Path) -> str | None: + candidate_path = Path(str(output_file)) + if candidate_path.is_absolute(): + try: + return candidate_path.relative_to(root).as_posix() + except ValueError: + return None + + raw_path = str(output_file).replace("\\", "/") + pure_path = PurePosixPath(raw_path) + parts = [part for part in pure_path.parts if part not in {"", "/", "."}] + if parts and parts[0] == "output": + parts = parts[1:] + if not parts or any(part == ".." for part in parts): + return None + return "/".join(parts) + + +def _collect_output_relative_paths(*, sandbox: Any, root: Path) -> set[str]: + relative_paths: set[str] = set() + + if hasattr(sandbox, "get_output_files"): + try: + output_files = cast(Sequence[object], sandbox.get_output_files()) + except Exception: + output_files = () + + for output_file in output_files: + if (relative_path := _normalize_output_relative_path(output_file=output_file, root=root)) is not None: + relative_paths.add(relative_path) + + for host_path in root.rglob("*"): + if host_path.is_file(): + relative_paths.add(host_path.relative_to(root).as_posix()) + + return relative_paths + + +def _parse_output_files( + *, + sandbox: Any, + output_dir: TemporaryDirectory[str] | None, + expect_output_files: bool, +) -> list[Content]: + if output_dir is None: + return [] + + root = Path(output_dir.name) + + for attempt in range(OUTPUT_FILE_RETRY_ATTEMPTS): + relative_paths = _collect_output_relative_paths(sandbox=sandbox, root=root) + missing_files = expect_output_files and not relative_paths + contents: list[Content] = [] + + for relative_path in sorted(relative_paths): + host_path = root.joinpath(*PurePosixPath(relative_path).parts) + if not host_path.is_file(): + missing_files = True + continue + try: + contents.append(_create_file_content(host_path, relative_path=relative_path)) + except PermissionError: + missing_files = True + + if not missing_files or attempt == OUTPUT_FILE_RETRY_ATTEMPTS - 1: + return contents + + time.sleep(OUTPUT_FILE_RETRY_DELAY_SECONDS) + + return [] + + +def _build_execution_contents( + *, + result: Any, + sandbox: Any, + output_dir: TemporaryDirectory[str] | None, + code: str, +) -> list[Content]: + success = bool(getattr(result, "success", False)) + stdout = str(getattr(result, "stdout", "") or "").replace("\r\n", "\n") or None + stderr = str(getattr(result, "stderr", "") or "").replace("\r\n", "\n") or None + outputs: list[Content] = [] + + if stdout is not None: + outputs.append(Content.from_text(stdout, raw_representation=result)) + + outputs.extend( + _parse_output_files( + sandbox=sandbox, + output_dir=output_dir, + expect_output_files="/output" in code, + ) + ) + + if success: + if stderr is not None: + outputs.append(Content.from_text(stderr, raw_representation=result)) + if not outputs: + outputs.append(Content.from_text("Code executed successfully without output.")) + return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + + error_details = stderr or "Unknown sandbox error" + outputs.append( + Content.from_error( + message="Execution error", + error_details=error_details, + raw_representation=result, + ) + ) + return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + + +def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]: + sandbox_tool = copy.copy(tool_obj) + sandbox_tool.result_parser = _passthrough_result_parser + + def _callback(**kwargs: Any) -> Any: + async def _invoke() -> list[Content]: + return await sandbox_tool.invoke(arguments=kwargs) + + # FunctionTool.invoke() is always async. The real Hyperlight backend invokes + # registered callbacks synchronously via FFI, so this must be a sync function. + # We run the async call on a dedicated thread to avoid conflicts with any + # event loop that may be running on the current thread. + result_box: list[Any] = [None] + error_box: list[BaseException] = [] + + def _run() -> None: + try: + result_box[0] = asyncio.run(_invoke()) + except BaseException as exc: + error_box.append(exc) + + worker = threading.Thread(target=_run) + worker.start() + worker.join() + if error_box: + raise error_box[0] + contents: list[Content] = result_box[0] + + values: list[Any] = [] + for content in contents: + if content.type == "text" and content.text is not None: + try: + values.append(ast.literal_eval(content.text)) + except (SyntaxError, ValueError): + values.append(content.text) + continue + + values.append(content.to_dict()) + + if len(values) == 1: + return values[0] + return values + + return _callback + + +def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None: + """Remove all contents of the output directory without deleting the directory itself.""" + if output_dir is None: + return + root = Path(output_dir.name) + for child in root.iterdir(): + try: + if child.is_symlink() or child.is_file(): + child.unlink() + elif child.is_dir(): + shutil.rmtree(child, ignore_errors=True) + except (FileNotFoundError, PermissionError): + pass + + +class _SandboxRegistry: + def __init__(self) -> None: + self._entries: dict[tuple[Any, ...], _SandboxEntry] = {} + self._entries_lock = threading.RLock() + + def execute(self, *, config: _RunConfig, code: str) -> list[Content]: + """Execute code in a cached sandbox matching the given config. + + Entries are keyed by ``config.cache_key()``. Concurrent calls with the same + key are serialized by the entry lock so they never race, but they share the + same sandbox instance. For true parallel execution, use distinct provider + instances or configs that produce different cache keys. + """ + cache_key = config.cache_key() + with self._entries_lock: + entry = self._entries.get(cache_key) + if entry is None: + entry = self._create_entry(config) + self._entries[cache_key] = entry + + with entry.lock: + entry.sandbox.restore(entry.snapshot) + _clear_directory(entry.output_dir) + result = entry.sandbox.run(code=code) + return _build_execution_contents( + result=result, + sandbox=entry.sandbox, + output_dir=entry.output_dir, + code=code, + ) + + def _create_entry(self, config: _RunConfig) -> _SandboxEntry: + input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None + output_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None + + if input_dir_handle is not None: + _populate_input_dir(config=config, input_root=Path(input_dir_handle.name)) + + sandbox_cls = _load_sandbox_class() + + def _create_sandbox() -> Any: + try: + return sandbox_cls( + backend=config.backend, + module=config.module, + module_path=config.module_path, + input_dir=input_dir_handle.name if input_dir_handle is not None else None, + output_dir=output_dir_handle.name if output_dir_handle is not None else None, + ) + except ImportError as exc: + raise RuntimeError( + "The selected Hyperlight backend is not installed or not supported on this platform. " + "Install a compatible backend package, such as `hyperlight-sandbox-backend-wasm`." + ) from exc + + def _configure_sandbox(*, sandbox: Any, expand_missing_scheme: bool) -> None: + for tool_obj in config.tools: + sandbox.register_tool(tool_obj.name, _make_sandbox_callback(tool_obj)) + + for allowed_domain in config.allowed_domains: + for target in _allowed_domain_registration_targets( + target=allowed_domain.target, + expand_missing_scheme=expand_missing_scheme, + ): + sandbox.allow_domain( + target, + methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None, + ) + + sandbox = _create_sandbox() + _configure_sandbox(sandbox=sandbox, expand_missing_scheme=False) + + try: + sandbox.run("None") + except RuntimeError as exc: + if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains): + raise + + sandbox = _create_sandbox() + _configure_sandbox(sandbox=sandbox, expand_missing_scheme=True) + sandbox.run("None") + + snapshot = sandbox.snapshot() + return _SandboxEntry( + sandbox=sandbox, + snapshot=snapshot, + input_dir=input_dir_handle, + output_dir=output_dir_handle, + lock=threading.RLock(), + ) + + +class HyperlightExecuteCodeTool(FunctionTool): + """Execute Python code inside a Hyperlight sandbox.""" + + def __init__( + self, + *, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + approval_mode: ApprovalMode | None = None, + workspace_root: str | Path | None = None, + file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, + allowed_domains: AllowedDomainInput | Sequence[AllowedDomainInput] | None = None, + backend: str = DEFAULT_HYPERLIGHT_BACKEND, + module: str | None = DEFAULT_HYPERLIGHT_MODULE, + module_path: str | None = None, + _registry: SandboxRuntime | None = None, + ) -> None: + super().__init__( + name="execute_code", + description=EXECUTE_CODE_INPUT_DESCRIPTION, + approval_mode="never_require", + func=self._run_code, + input_model=_ExecuteCodeInput, + ) + self._state_lock = threading.RLock() + self._registry = _registry or _SandboxRegistry() + self._default_approval_mode: ApprovalMode = approval_mode or "never_require" + self._workspace_root = _resolve_workspace_root(workspace_root) + self._backend: str = backend + self._module: str | None = module + self._module_path: str | None = module_path + self._managed_tools: list[FunctionTool] = [] + self._file_mounts: dict[str, _StoredFileMount] = {} + self._allowed_domains: dict[str, AllowedDomain] = {} + + if tools is not None: + self.add_tools(tools) + if file_mounts is not None: + self.add_file_mounts(file_mounts) + if allowed_domains is not None: + self.add_allowed_domains(allowed_domains) + + self._refresh_approval_mode() + + @property + def description(self) -> str: + state_lock = getattr(self, "_state_lock", None) + if state_lock is None: + return str(self.__dict__.get("description", EXECUTE_CODE_INPUT_DESCRIPTION)) + + with state_lock: + allowed_domains = sorted(self._allowed_domains.values(), key=lambda value: value.target) + return build_execute_code_description( + tools=self._managed_tools, + filesystem_enabled=self._workspace_root is not None or bool(self._file_mounts), + workspace_enabled=self._workspace_root is not None, + mounted_paths=[_display_mount_path(mount.mount_path) for mount in self._file_mounts.values()], + allowed_domains=allowed_domains, + ) + + @description.setter + def description(self, value: str) -> None: + self.__dict__["description"] = value + + def add_tools( + self, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]], + ) -> None: + """Add sandbox-managed tools to this execute_code surface.""" + with self._state_lock: + combined_tools = _collect_tools(self._managed_tools, tools) + self._managed_tools = combined_tools + self._refresh_approval_mode() + + def get_tools(self) -> list[FunctionTool]: + """Return the currently managed sandbox tools.""" + with self._state_lock: + return list(self._managed_tools) + + def remove_tool(self, name: str) -> None: + """Remove one managed sandbox tool by name.""" + with self._state_lock: + remaining_tools = [tool_obj for tool_obj in self._managed_tools if tool_obj.name != name] + if len(remaining_tools) == len(self._managed_tools): + raise KeyError(f"No managed tool named {name!r} is registered.") + self._managed_tools = remaining_tools + self._refresh_approval_mode() + + def clear_tools(self) -> None: + """Remove all managed sandbox tools.""" + with self._state_lock: + self._managed_tools = [] + self._refresh_approval_mode() + + def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None: + """Add one or more file mounts under `/input`. + + A single string uses the same relative path on the host and in the sandbox. + Use a two-string tuple or `FileMount` when those paths differ. + """ + if isinstance(file_mounts, str) or _is_file_mount_pair(file_mounts): + normalized_mounts = [_normalize_file_mount_input(file_mounts)] + else: + normalized_mounts = [ + _normalize_file_mount_input(mount) for mount in cast(Sequence[FileMountInput], file_mounts) + ] + + with self._state_lock: + for mount in normalized_mounts: + self._file_mounts[mount.mount_path] = mount + + def get_file_mounts(self) -> list[FileMount]: + """Return the configured file mounts.""" + with self._state_lock: + return [ + FileMount(host_path=mount.host_path, mount_path=_display_mount_path(mount.mount_path)) + for mount in self._file_mounts.values() + ] + + def remove_file_mount(self, mount_path: str) -> None: + """Remove one file mount by its sandbox path.""" + normalized_mount_path = _normalize_mount_path(mount_path) + with self._state_lock: + if normalized_mount_path not in self._file_mounts: + raise KeyError(f"No file mount exists for {mount_path!r}.") + del self._file_mounts[normalized_mount_path] + + def clear_file_mounts(self) -> None: + """Remove all configured file mounts.""" + with self._state_lock: + self._file_mounts.clear() + + def add_allowed_domains(self, domains: AllowedDomainInput | Sequence[AllowedDomainInput]) -> None: + """Add one or more outbound allow-list entries.""" + if isinstance(domains, (str, AllowedDomain)) or _is_allowed_domain_pair(domains): + normalized_domains = [_normalize_allowed_domain_input(domains)] + else: + normalized_domains = [ + _normalize_allowed_domain_input(domain) for domain in cast(Sequence[AllowedDomainInput], domains) + ] + + with self._state_lock: + for normalized_domain in normalized_domains: + self._allowed_domains[normalized_domain.target] = normalized_domain + + def get_allowed_domains(self) -> list[AllowedDomain]: + """Return the configured outbound allow-list entries.""" + with self._state_lock: + return sorted(self._allowed_domains.values(), key=lambda value: value.target) + + def remove_allowed_domain(self, domain: str) -> None: + """Remove one outbound allow-list entry.""" + normalized_domain = _normalize_domain(domain) + with self._state_lock: + if normalized_domain not in self._allowed_domains: + raise KeyError(f"No allowed domain exists for {domain!r}.") + del self._allowed_domains[normalized_domain] + + def clear_allowed_domains(self) -> None: + """Remove all outbound allow-list entries.""" + with self._state_lock: + self._allowed_domains.clear() + + def build_instructions(self, *, tools_visible_to_model: bool) -> str: + """Build the current CodeAct instructions for this execute_code surface.""" + config = self._build_run_config() + return build_codeact_instructions( + tools=config.tools, + tools_visible_to_model=tools_visible_to_model, + ) + + def create_run_tool(self) -> HyperlightExecuteCodeTool: + """Create a run-scoped snapshot of this execute_code surface.""" + file_mounts = self.get_file_mounts() + allowed_domains = self.get_allowed_domains() + + return HyperlightExecuteCodeTool( + tools=self.get_tools(), + approval_mode=self._default_approval_mode, + workspace_root=self._workspace_root, + file_mounts=file_mounts or None, + allowed_domains=allowed_domains or None, + backend=self._backend, + module=self._module, + module_path=self._module_path, + _registry=self._registry, + ) + + def build_serializable_state(self) -> dict[str, Any]: + """Return a JSON-serializable snapshot of the effective run state.""" + config = self._build_run_config() + return { + "backend": config.backend, + "module": config.module, + "module_path": config.module_path, + "approval_mode": config.approval_mode, + "tool_names": [tool_obj.name for tool_obj in config.tools], + "filesystem_enabled": config.filesystem_enabled, + "workspace_root": str(config.workspace_root) if config.workspace_root is not None else None, + "file_mounts": [ + { + "host_path": str(mount.host_path), + "mount_path": _display_mount_path(mount.mount_path), + } + for mount in config.file_mounts + ], + "network_enabled": bool(config.allowed_domains), + "allowed_domains": [ + { + "target": allowed_domain.target, + "methods": list(allowed_domain.methods) if allowed_domain.methods is not None else None, + } + for allowed_domain in config.allowed_domains + ], + } + + def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]: + self.__dict__["description"] = self.description + return super().to_dict(exclude=exclude, exclude_none=exclude_none) + + def _refresh_approval_mode(self) -> None: + self.approval_mode = _resolve_execute_code_approval_mode( + base_approval_mode=self._default_approval_mode, + tools=self._managed_tools, + ) + + def _build_run_config(self) -> _RunConfig: + with self._state_lock: + managed_tools = tuple(self._managed_tools) + workspace_root = self._workspace_root + stored_mounts = tuple(self._file_mounts.values()) + allowed_domains = tuple(sorted(self._allowed_domains.values(), key=lambda value: value.target)) + approval_mode = _resolve_execute_code_approval_mode( + base_approval_mode=self._default_approval_mode, + tools=managed_tools, + ) + + workspace_signature = _path_tree_signature(workspace_root) if workspace_root is not None else () + normalized_mounts = tuple( + _NormalizedFileMount( + host_path=mount.host_path, + mount_path=mount.mount_path, + path_signature=_path_tree_signature(mount.host_path), + ) + for mount in stored_mounts + ) + + return _RunConfig( + backend=self._backend, + module=self._module, + module_path=self._module_path, + approval_mode=approval_mode, + tools=managed_tools, + workspace_root=workspace_root, + workspace_signature=workspace_signature, + file_mounts=normalized_mounts, + allowed_domains=allowed_domains, + ) + + async def _run_code(self, *, code: str) -> list[Content]: + config = self._build_run_config() + return await asyncio.to_thread(self._registry.execute, config=config, code=code) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py new file mode 100644 index 0000000000..f866c1349c --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence + +from agent_framework import FunctionTool + +from ._types import AllowedDomain + + +def _format_tool_summaries(tools: Sequence[FunctionTool]) -> str: + if not tools: + return "- No tools are currently registered inside the sandbox." + + lines: list[str] = [] + for tool_obj in tools: + parameters = tool_obj.parameters().get("properties", {}) + parameter_names = [name for name in parameters if isinstance(name, str)] + parameter_summary = ", ".join(parameter_names) if parameter_names else "none" + description = str(tool_obj.description or "").strip() or "No description provided." + lines.append(f"- `{tool_obj.name}`: {description} Parameters: {parameter_summary}.") + return "\n".join(lines) + + +def _format_filesystem_capabilities( + *, + filesystem_enabled: bool, + workspace_enabled: bool, + mounted_paths: Sequence[str], +) -> str: + if not filesystem_enabled: + return "Filesystem access is unavailable because no workspace root or file mounts are configured." + + lines = ["Filesystem access is enabled."] + lines.append("Read files from `/input`.") + lines.append("Write generated artifacts to `/output`; returned files will be attached to the tool result.") + + if workspace_enabled: + lines.append("The configured workspace root is available under `/input/`.") + + if mounted_paths: + lines.append("Additional mounted paths:") + lines.extend(f"- `{mounted_path}`" for mounted_path in mounted_paths) + elif not workspace_enabled: + lines.append("No workspace root or explicit file mounts are currently configured.") + + return "\n".join(lines) + + +def _format_network_capabilities( + *, + allowed_domains: Sequence[AllowedDomain], +) -> str: + if not allowed_domains: + return "Outbound network access is unavailable because no allow-listed targets are configured." + + lines = ["Outbound network access is allowed only for these configured targets:"] + for allowed_domain in allowed_domains: + methods_text = ( + ", ".join(allowed_domain.methods) if allowed_domain.methods else "all methods allowed by the backend" + ) + lines.append(f"- `{allowed_domain.target}`: {methods_text}.") + return "\n".join(lines) + + +def build_codeact_instructions( + *, + tools: Sequence[FunctionTool], + tools_visible_to_model: bool, +) -> str: + """Build dynamic CodeAct instructions for the effective sandbox state.""" + usage_note = ( + "Some tools may also appear directly, but prefer `execute_code` whenever you need to combine Python " + "control flow with sandbox tool calls." + if tools_visible_to_model + else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them." + ) + + return f"""You have one primary tool: execute_code. + +Prefer one execute_code call per request when possible. +Its tool description contains the current `call_tool(...)` guidance, sandbox +tool registry, and capability limits. + +{usage_note} +""" + + +def build_execute_code_description( + *, + tools: Sequence[FunctionTool], + filesystem_enabled: bool, + workspace_enabled: bool, + mounted_paths: Sequence[str], + allowed_domains: Sequence[AllowedDomain], +) -> str: + """Build the dynamic execute_code tool description for standalone usage.""" + filesystem_text = _format_filesystem_capabilities( + filesystem_enabled=filesystem_enabled, + workspace_enabled=workspace_enabled, + mounted_paths=mounted_paths, + ) + network_text = _format_network_capabilities( + allowed_domains=allowed_domains, + ) + + return f"""Execute Python in an isolated Hyperlight sandbox. + +Inside the sandbox, `call_tool(name, **kwargs)` is available as a built-in for +registered host callbacks. Use the tool name as the first argument and keyword +arguments only. Do not pass a dict or any other positional arguments after the +tool name. + +Registered sandbox tools: +{_format_tool_summaries(tools)} + +Filesystem capabilities: +{filesystem_text} + +Network capabilities: +{network_text} + +Prefer `execute_code` when you need to combine one or more `call_tool(...)` +calls with Python control flow, loops, or post-processing. +""" diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_provider.py b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py new file mode 100644 index 0000000000..1232ecc262 --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/_provider.py @@ -0,0 +1,111 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +from agent_framework import AgentSession, ContextProvider, FunctionTool, SessionContext +from agent_framework._tools import ApprovalMode + +from ._execute_code_tool import HyperlightExecuteCodeTool, SandboxRuntime +from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountInput + + +class HyperlightCodeActProvider(ContextProvider): + """Inject a Hyperlight-backed CodeAct surface using provider-owned tools.""" + + DEFAULT_SOURCE_ID = "hyperlight_codeact" + + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + *, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None, + approval_mode: ApprovalMode | None = None, + workspace_root: str | Path | None = None, + file_mounts: FileMountInput | Sequence[FileMountInput] | None = None, + allowed_domains: AllowedDomainInput | Sequence[AllowedDomainInput] | None = None, + backend: str = "wasm", + module: str | None = "python_guest.path", + module_path: str | None = None, + _registry: SandboxRuntime | None = None, + ) -> None: + super().__init__(source_id) + self._execute_code_tool = HyperlightExecuteCodeTool( + tools=tools, + approval_mode=approval_mode, + workspace_root=workspace_root, + file_mounts=file_mounts, + allowed_domains=allowed_domains, + backend=backend, + module=module, + module_path=module_path, + _registry=_registry, + ) + + def add_tools( + self, + tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]], + ) -> None: + """Add provider-owned sandbox tools.""" + self._execute_code_tool.add_tools(tools) + + def get_tools(self) -> list[FunctionTool]: + """Return the provider-owned sandbox tools.""" + return self._execute_code_tool.get_tools() + + def remove_tool(self, name: str) -> None: + """Remove one provider-owned sandbox tool by name.""" + self._execute_code_tool.remove_tool(name) + + def clear_tools(self) -> None: + """Remove all provider-owned sandbox tools.""" + self._execute_code_tool.clear_tools() + + def add_file_mounts(self, file_mounts: FileMountInput | Sequence[FileMountInput]) -> None: + """Add provider-managed file mounts.""" + self._execute_code_tool.add_file_mounts(file_mounts) + + def get_file_mounts(self) -> list[FileMount]: + """Return the provider-managed file mounts.""" + return self._execute_code_tool.get_file_mounts() + + def remove_file_mount(self, mount_path: str) -> None: + """Remove one provider-managed file mount.""" + self._execute_code_tool.remove_file_mount(mount_path) + + def clear_file_mounts(self) -> None: + """Remove all provider-managed file mounts.""" + self._execute_code_tool.clear_file_mounts() + + def add_allowed_domains(self, domains: AllowedDomainInput | Sequence[AllowedDomainInput]) -> None: + """Add provider-managed outbound allow-list entries.""" + self._execute_code_tool.add_allowed_domains(domains) + + def get_allowed_domains(self) -> list[AllowedDomain]: + """Return the provider-managed outbound allow-list entries.""" + return self._execute_code_tool.get_allowed_domains() + + def remove_allowed_domain(self, domain: str) -> None: + """Remove one provider-managed outbound allow-list entry.""" + self._execute_code_tool.remove_allowed_domain(domain) + + def clear_allowed_domains(self) -> None: + """Remove all provider-managed outbound allow-list entries.""" + self._execute_code_tool.clear_allowed_domains() + + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Inject CodeAct instructions and a run-scoped execute_code tool before each run.""" + run_tool = self._execute_code_tool.create_run_tool() + state[self.source_id] = run_tool.build_serializable_state() + context.extend_instructions(self.source_id, run_tool.build_instructions(tools_visible_to_model=False)) + context.extend_tools(self.source_id, [run_tool]) diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_types.py b/python/packages/hyperlight/agent_framework_hyperlight/_types.py new file mode 100644 index 0000000000..8d202c8986 --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/_types.py @@ -0,0 +1,28 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from typing import NamedTuple, TypeAlias + + +class FileMount(NamedTuple): + """Map a host file or directory into the sandbox input tree.""" + + host_path: str | Path + mount_path: str + + +FileMountHostPath: TypeAlias = str | Path +FileMountInput: TypeAlias = str | tuple[FileMountHostPath, str] | FileMount + + +class AllowedDomain(NamedTuple): + """Allow outbound requests to one target, optionally restricted to specific HTTP methods.""" + + target: str + methods: tuple[str, ...] | None = None + + +AllowedDomainInput: TypeAlias = str | tuple[str, str | Sequence[str]] | AllowedDomain diff --git a/python/packages/hyperlight/agent_framework_hyperlight/py.typed b/python/packages/hyperlight/agent_framework_hyperlight/py.typed new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/python/packages/hyperlight/agent_framework_hyperlight/py.typed @@ -0,0 +1 @@ + diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml new file mode 100644 index 0000000000..9884152043 --- /dev/null +++ b/python/packages/hyperlight/pyproject.toml @@ -0,0 +1,101 @@ +[project] +name = "agent-framework-hyperlight" +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.0a260409" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0,<2", + "hyperlight-sandbox>=0.3.0,<0.4", + "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; (sys_platform == 'linux' or sys_platform == 'win32') and python_version < '3.14'", + "hyperlight-sandbox-python-guest>=0.3.0,<0.4", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.ruff.lint.per-file-ignores] +"samples/**" = ["INP", "T201"] +"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"] + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_hyperlight"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_hyperlight"] +exclude_dirs = ["tests", "samples"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_hyperlight" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_hyperlight --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/hyperlight/samples/README.md b/python/packages/hyperlight/samples/README.md new file mode 100644 index 0000000000..aa6aeeee1c --- /dev/null +++ b/python/packages/hyperlight/samples/README.md @@ -0,0 +1,43 @@ +# Hyperlight CodeAct samples + +These samples demonstrate the alpha `agent-framework-hyperlight` package. + +## When to use which pattern + +- **Provider pattern** (`codeact_context_provider.py`): Use when the tool + registry, file mounts, or network allow-list may change between runs, or when + you want the provider to manage CodeAct instructions and approval computation + automatically on every invocation. This is the recommended default for + production agents that need dynamic capability management or concurrent runs + sharing one provider. + +- **Manual static wiring** (`codeact_manual_wiring.py`): Use when the sandbox + tool set and capabilities are fixed for the agent's lifetime. This pattern + builds instructions once, passes `execute_code` alongside direct tools in + `tools=`, and skips the per-run provider lifecycle entirely. Simpler setup, + but changes to the tool registry after construction will not update the + agent's instructions automatically. + +- **Standalone tool** (`codeact_tool.py`): Use for the simplest integration + where `execute_code` is added directly to the agent tool list. The tool's own + description advertises `call_tool(...)` and the registered sandbox tools, so + no extra agent instructions are needed. Best for quick prototyping or when + CodeAct is just another tool alongside the agent's direct tools. + +## Samples + +- `codeact_context_provider.py` shows the provider-owned CodeAct model where the + agent only sees `execute_code` and sandbox tools are owned by + `HyperlightCodeActProvider`. +- `codeact_manual_wiring.py` shows static wiring where `HyperlightExecuteCodeTool` + and its instructions are passed directly to the `Agent` constructor. +- `codeact_tool.py` shows the standalone `HyperlightExecuteCodeTool` surface + where `execute_code` is added directly to the agent tool list. + +Run the samples from the repository after installing the workspace dependencies: + +```bash +uv run --directory packages/hyperlight python samples/codeact_context_provider.py +uv run --directory packages/hyperlight python samples/codeact_manual_wiring.py +uv run --directory packages/hyperlight python samples/codeact_tool.py +``` diff --git a/python/packages/hyperlight/samples/codeact_context_provider.py b/python/packages/hyperlight/samples/codeact_context_provider.py new file mode 100644 index 0000000000..c0cc03c2f6 --- /dev/null +++ b/python/packages/hyperlight/samples/codeact_context_provider.py @@ -0,0 +1,192 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import Awaitable, Callable +from typing import Annotated, Any, Literal + +from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework_hyperlight import HyperlightCodeActProvider + +"""This sample demonstrates the provider-owned Hyperlight CodeAct flow. + +The sample keeps `compute` and `fetch_data` off the direct agent tool surface and +registers them only with `HyperlightCodeActProvider`. The model therefore sees a +single `execute_code` tool and must call the provider-owned tools from inside +the sandbox with `call_tool(...)`. +""" + +load_dotenv() + +_CYAN = "\033[36m" +_YELLOW = "\033[33m" +_GREEN = "\033[32m" +_DIM = "\033[2m" +_RESET = "\033[0m" + + +class _ColoredFormatter(logging.Formatter): + """Dim logger output so it does not compete with sample prints.""" + + def format(self, record: logging.LogRecord) -> str: + return f"{_DIM}{super().format(record)}{_RESET}" + + +logging.basicConfig(level=logging.WARNING) +logging.getLogger().handlers[0].setFormatter( + _ColoredFormatter("[%(asctime)s] %(levelname)s: %(message)s"), +) + + +@function_middleware +async def log_function_calls( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], +) -> None: + """Log tool calls, including readable execute_code blocks.""" + import time + + function_name = context.function.name + arguments = context.arguments if isinstance(context.arguments, dict) else {} + + if function_name == "execute_code" and "code" in arguments: + print(f"\n{_YELLOW}{'─' * 60}") + print("▶ execute_code") + print(f"{'─' * 60}{_RESET}") + print(arguments["code"]) + print(f"{_YELLOW}{'─' * 60}{_RESET}") + else: + pairs = ", ".join(f"{name}={value!r}" for name, value in arguments.items()) + print(f"\n{_YELLOW}▶ {function_name}({pairs}){_RESET}") + + start = time.perf_counter() + await call_next() + elapsed = time.perf_counter() - start + + result = context.result + if function_name == "execute_code" and isinstance(result, list): + for item in result: + if item.type != "code_interpreter_tool_result": + continue + + for output in item.outputs or []: + if output.type == "text" and output.text: + print(f"{_GREEN}stdout:\n{output.text}{_RESET}") + if output.type == "error" and output.error_details: + print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") + else: + print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}") + + print(f"{_DIM} ({elapsed:.4f}s){_RESET}") + + +@tool(approval_mode="never_require") +def compute( + operation: Annotated[ + Literal["add", "subtract", "multiply", "divide"], + "Math operation: add, subtract, multiply, or divide.", + ], + a: Annotated[float, "First numeric operand."], + b: Annotated[float, "Second numeric operand."], +) -> float: + """Perform a math operation for sandboxed code.""" + operations = { + "add": a + b, + "subtract": a - b, + "multiply": a * b, + "divide": a / b if b else float("inf"), + } + return operations[operation] + + +@tool(approval_mode="never_require") +async def fetch_data( + table: Annotated[str, "Name of the simulated table to query."], +) -> list[dict[str, Any]]: + """Fetch records from a named table.""" + await asyncio.sleep(0.5) + data: dict[str, list[dict[str, Any]]] = { + "users": [ + {"id": 1, "name": "Alice", "role": "admin"}, + {"id": 2, "name": "Bob", "role": "user"}, + {"id": 3, "name": "Charlie", "role": "admin"}, + ], + "products": [ + {"id": 101, "name": "Widget", "price": 9.99}, + {"id": 102, "name": "Gadget", "price": 19.99}, + ], + } + return data.get(table, []) + + +async def main() -> None: + """Run the provider-owned Hyperlight CodeAct sample.""" + # 1. Create the Hyperlight-backed provider and register sandbox tools on it. + codeact = HyperlightCodeActProvider( + tools=[compute, fetch_data], + approval_mode="never_require", + ) + + # 2. Create the client and the agent. + agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), + name="HyperlightCodeActProviderAgent", + instructions="You are a helpful assistant.", + context_providers=[codeact], + middleware=[log_function_calls], + ) + + # 3. Run a request that should use execute_code plus provider-owned tools. + query = ( + "Fetch all users, find admins, multiply 7*(3*2), and print the users, " + "admins, and multiplication result. Use execute_code and call_tool(...) " + "inside the sandbox." + ) + print(f"{_CYAN}{'=' * 60}") + print("Hyperlight CodeAct provider sample") + print(f"{'=' * 60}{_RESET}") + print(f"{_CYAN}User: {query}{_RESET}") + result = await agent.run(query) + print(f"{_CYAN}Agent: {result.text}{_RESET}") + + +""" +Sample output (shape only): + +============================================================ +Hyperlight CodeAct provider sample +============================================================ +User: Fetch all users, find admins, multiply 7*(3*2), ... + +──────────────────────────────────────────────────────────── +▶ execute_code +──────────────────────────────────────────────────────────── +users = call_tool("fetch_data", table="users") +admins = [user for user in users if user["role"] == "admin"] +result = call_tool("compute", operation="multiply", a=7, b=6) +print("Users:", users) +print("Admins:", admins) +print("7 * 6 =", result) +──────────────────────────────────────────────────────────── +stdout: +Users: [...] +Admins: [...] +7 * 6 = 42.0 + (0.0xxx s) +Agent: ... +""" + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/hyperlight/samples/codeact_manual_wiring.py b/python/packages/hyperlight/samples/codeact_manual_wiring.py new file mode 100644 index 0000000000..c7a4761efb --- /dev/null +++ b/python/packages/hyperlight/samples/codeact_manual_wiring.py @@ -0,0 +1,133 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import os +from typing import Annotated, Any, Literal + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework_hyperlight import HyperlightExecuteCodeTool + +"""This sample demonstrates manual static wiring of CodeAct without a provider. + +Instead of using `HyperlightCodeActProvider` with `context_providers=`, this +sample creates a `HyperlightExecuteCodeTool` directly, extracts its CodeAct +instructions once, and passes both to the `Agent` constructor at build time. + +This avoids the per-run provider lifecycle (`before_run` / `after_run`) and is +well-suited when the tool registry, file mounts, and network allow-list are +fixed for the agent's lifetime. The tradeoff is that dynamic tool or capability +changes between runs are not supported — any mutations to the tool would not +update the agent's instructions automatically. +""" + +load_dotenv() + + +@tool(approval_mode="never_require") +def compute( + operation: Annotated[ + Literal["add", "subtract", "multiply", "divide"], + "Math operation: add, subtract, multiply, or divide.", + ], + a: Annotated[float, "First numeric operand."], + b: Annotated[float, "Second numeric operand."], +) -> float: + """Perform a math operation used by sandboxed code.""" + operations = { + "add": a + b, + "subtract": a - b, + "multiply": a * b, + "divide": a / b if b else float("inf"), + } + return operations[operation] + + +@tool(approval_mode="never_require") +def fetch_data( + table: Annotated[str, "Name of the simulated table to query."], +) -> list[dict[str, Any]]: + """Fetch simulated records from a named table.""" + data: dict[str, list[dict[str, Any]]] = { + "users": [ + {"id": 1, "name": "Alice", "role": "admin"}, + {"id": 2, "name": "Bob", "role": "user"}, + {"id": 3, "name": "Charlie", "role": "admin"}, + ], + "products": [ + {"id": 101, "name": "Widget", "price": 9.99}, + {"id": 102, "name": "Gadget", "price": 19.99}, + ], + } + return data.get(table, []) + + +@tool(approval_mode="never_require") +def send_email( + to: Annotated[str, "Recipient email address."], + subject: Annotated[str, "Email subject line."], + body: Annotated[str, "Email body text."], +) -> str: + """Simulate sending an email (direct-only tool, not available inside the sandbox).""" + return f"Email sent to {to}: {subject}" + + +async def main() -> None: + """Run the manual static-wiring sample.""" + # 1. Create the execute_code tool and register sandbox tools on it. + execute_code = HyperlightExecuteCodeTool( + tools=[compute, fetch_data], + approval_mode="never_require", + ) + + # 2. Build CodeAct instructions once. Setting tools_visible_to_model=False + # tells the instructions builder that sandbox tools are not in the agent's + # direct tool list, so the model must use call_tool(...) inside execute_code. + codeact_instructions = execute_code.build_instructions(tools_visible_to_model=False) + + # 3. Create the client and the agent with everything wired at construction time. + # - send_email is a direct-only tool (not available inside the sandbox). + # - execute_code carries sandbox tools (compute, fetch_data) via call_tool. + agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), + name="ManualWiringAgent", + instructions=f"You are a helpful assistant.\n\n{codeact_instructions}", + tools=[send_email, execute_code], + ) + + # 4. Run a request that exercises both the sandbox and the direct tool. + print("=" * 60) + print("Manual static-wiring CodeAct sample") + print("=" * 60) + query = ( + "Fetch all users, find admins, multiply 6*7, and print the users, admins, " + "and multiplication result. Use one execute_code call. " + "Then send an email to admin@example.com summarising the results." + ) + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}") + + +""" +Sample output (shape only): + +============================================================ +Manual static-wiring CodeAct sample +============================================================ +User: Fetch all users, find admins, multiply 6*7, ... +Agent: ... +""" + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/hyperlight/samples/codeact_tool.py b/python/packages/hyperlight/samples/codeact_tool.py new file mode 100644 index 0000000000..64c0e6fde5 --- /dev/null +++ b/python/packages/hyperlight/samples/codeact_tool.py @@ -0,0 +1,110 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import os +from typing import Annotated, Any, Literal + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +from agent_framework_hyperlight import HyperlightExecuteCodeTool + +"""This sample demonstrates the standalone Hyperlight execute_code tool. + +The sample adds `HyperlightExecuteCodeTool` directly to the agent. The tool's +own description advertises `call_tool(...)`, the registered sandbox tools, and +the current capability configuration, so no extra CodeAct-specific agent +instructions are required. +""" + +load_dotenv() + + +@tool(approval_mode="never_require") +def compute( + operation: Annotated[ + Literal["add", "subtract", "multiply", "divide"], + "Math operation: add, subtract, multiply, or divide.", + ], + a: Annotated[float, "First numeric operand."], + b: Annotated[float, "Second numeric operand."], +) -> float: + """Perform a math operation used by sandboxed code.""" + operations = { + "add": a + b, + "subtract": a - b, + "multiply": a * b, + "divide": a / b if b else float("inf"), + } + return operations[operation] + + +@tool(approval_mode="never_require") +def fetch_data( + table: Annotated[str, "Name of the simulated table to query."], +) -> list[dict[str, Any]]: + """Fetch simulated records from a named table.""" + data: dict[str, list[dict[str, Any]]] = { + "users": [ + {"id": 1, "name": "Alice", "role": "admin"}, + {"id": 2, "name": "Bob", "role": "user"}, + {"id": 3, "name": "Charlie", "role": "admin"}, + ], + "products": [ + {"id": 101, "name": "Widget", "price": 9.99}, + {"id": 102, "name": "Gadget", "price": 19.99}, + ], + } + return data.get(table, []) + + +async def main() -> None: + """Run the standalone execute_code sample.""" + # 1. Create the packaged execute_code tool and register sandbox tools on it. + execute_code = HyperlightExecuteCodeTool( + tools=[compute, fetch_data], + approval_mode="never_require", + ) + + # 2. Create the client and the agent. + agent = Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ), + name="HyperlightExecuteCodeToolAgent", + instructions="You are a helpful assistant.", + tools=execute_code, + ) + + # 3. Run one request through the direct-tool surface. + print("=" * 60) + print("Hyperlight execute_code tool sample") + print("=" * 60) + query = ( + "Fetch all users, find admins, multiply 6*7, and print the users, admins, " + "and multiplication result. Use one execute_code call." + ) + print(f"User: {query}") + result = await agent.run(query) + print(f"Agent: {result.text}") + + +""" +Sample output (shape only): + +============================================================ +Hyperlight execute_code tool sample +============================================================ +User: Fetch all users, find admins, multiply 6*7, ... +Agent: ... +""" + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py new file mode 100644 index 0000000000..528b6e3b5b --- /dev/null +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -0,0 +1,981 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import importlib.metadata +import importlib.util +import inspect +import json +import sys +import threading +import time +from collections.abc import Awaitable, Callable, Mapping, MutableSequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest +from agent_framework import ( + Agent, + BaseChatClient, + ChatResponse, + ChatResponseUpdate, + Content, + FunctionInvocationLayer, + FunctionTool, + Message, + ResponseStream, + tool, +) + +from agent_framework_hyperlight import AllowedDomain, FileMount, HyperlightCodeActProvider, HyperlightExecuteCodeTool +from agent_framework_hyperlight import _execute_code_tool as execute_code_module + + +def _hyperlight_integration_static_skip_reason() -> str | None: + if sys.version_info >= (3, 14): + return ( + "Hyperlight integration tests require Python < 3.14 because hyperlight-sandbox-backend-wasm is unsupported." + ) + + if sys.platform not in {"linux", "win32"}: + return "Hyperlight integration tests require Linux or Windows runners." + + if importlib.util.find_spec("hyperlight_sandbox") is None: + return "hyperlight-sandbox is not installed." + + if importlib.util.find_spec("python_guest") is None: + return "hyperlight-sandbox-python-guest is not installed." + + try: + importlib.metadata.version("hyperlight-sandbox-backend-wasm") + except importlib.metadata.PackageNotFoundError: + return "hyperlight-sandbox-backend-wasm is not installed." + + return None + + +def _hyperlight_integration_runtime_skip_reason() -> str | None: + if (reason := _hyperlight_integration_static_skip_reason()) is not None: + return reason + + try: + sandbox_cls = execute_code_module._load_sandbox_class() + sandbox = sandbox_cls( + backend=execute_code_module.DEFAULT_HYPERLIGHT_BACKEND, + module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE, + ) + sandbox.run("None") + except RuntimeError as exc: + message = str(exc) + if "no hypervisor was found for sandbox" in message.lower(): + return "Hyperlight integration tests require a runner with a working Hyperlight hypervisor." + + return None + + +def _skip_if_hyperlight_integration_runtime_disabled() -> None: + if (reason := _hyperlight_integration_runtime_skip_reason()) is not None: + pytest.skip(reason) + + +skip_if_hyperlight_integration_tests_disabled = pytest.mark.skipif( + (reason := _hyperlight_integration_static_skip_reason()) is not None, + reason=reason or "Hyperlight integration tests are disabled.", +) + + +@pytest.fixture(scope="module") +def shared_sandbox(): + """Long-lived sandbox with snapshot/restore for read-mostly tests. + + Multiple tests run sequentially against this fixture. Each test restores the + sandbox to a clean state via the ``restored_sandbox`` fixture. + """ + if (reason := _hyperlight_integration_runtime_skip_reason()) is not None: + pytest.skip(reason) + + sandbox_cls = execute_code_module._load_sandbox_class() + sandbox = sandbox_cls( + backend=execute_code_module.DEFAULT_HYPERLIGHT_BACKEND, + module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE, + ) + sandbox.run("None") + snapshot = sandbox.snapshot() + yield sandbox, snapshot + + +@pytest.fixture +def restored_sandbox(shared_sandbox): + """Restore shared sandbox to clean state before each test.""" + sandbox, snapshot = shared_sandbox + sandbox.restore(snapshot) + return sandbox + + +@pytest.fixture +def fresh_sandbox(): + """Short-lived sandbox for tests that alter config meaningfully. + + Not pre-warmed: call ``sandbox.run("None")`` after registering tools + and domains, then snapshot/restore before executing test code. + """ + if (reason := _hyperlight_integration_runtime_skip_reason()) is not None: + pytest.skip(reason) + + sandbox_cls = execute_code_module._load_sandbox_class() + sandbox = sandbox_cls( + backend=execute_code_module.DEFAULT_HYPERLIGHT_BACKEND, + module=execute_code_module.DEFAULT_HYPERLIGHT_MODULE, + temp_output=True, + ) + yield sandbox + + +@tool(approval_mode="never_require") +def compute(a: int, b: int) -> int: + return a + b + + +@tool(approval_mode="always_require") +def dangerous_compute(a: int, b: int) -> int: + return a * b + + +@tool(name="compute", approval_mode="always_require") +def replacement_compute(a: int, b: int) -> int: + return a - b + + +@dataclass(slots=True) +class _FakeResult: + success: bool + stdout: str = "" + stderr: str = "" + + +def _run_in_thread(callback: Callable[[], Any]) -> Any: + result: dict[str, Any] = {} + error: dict[str, BaseException] = {} + + def _runner() -> None: + try: + result["value"] = callback() + except BaseException as exc: + error["value"] = exc + + thread = threading.Thread(target=_runner) + thread.start() + thread.join() + + if "value" in error: + raise error["value"] + + return result.get("value") + + +class _FakeSandbox: + instances: list[_FakeSandbox] = [] + + def __init__( + self, + *, + input_dir: str | None = None, + output_dir: str | None = None, + temp_output: bool = False, + backend: str = "wasm", + module: str | None = None, + module_path: str | None = None, + heap_size: str | None = None, + stack_size: str | None = None, + ) -> None: + self.input_dir = input_dir + self.output_dir = output_dir + self.registered_tools: dict[str, Any] = {} + self.allowed_domains: list[tuple[str, list[str] | None]] = [] + self.restore_calls: list[Any] = [] + self.output_files: list[str] = [] + _FakeSandbox.instances.append(self) + + def register_tool(self, name_or_tool: Any, callback: Any | None = None) -> None: + if callback is None: + raise AssertionError("Expected callback registration for sandbox tools.") + self.registered_tools[str(name_or_tool)] = callback + + def allow_domain(self, target: str, methods: list[str] | None = None) -> None: + self.allowed_domains.append((target, methods)) + + def _invoke_tool(self, name: str, **kwargs: Any) -> Any: + callback = self.registered_tools[name] + if inspect.iscoroutinefunction(callback): + return _run_in_thread(lambda: asyncio.run(callback(**kwargs))) + + result = callback(**kwargs) + if inspect.isawaitable(result): + return _run_in_thread(lambda: asyncio.run(result)) + return result + + def run(self, code: str) -> _FakeResult: + if code == "None": + return _FakeResult(success=True) + if code == "create-output": + if self.output_dir is None: + raise AssertionError("Expected output directory for create-output test.") + Path(self.output_dir, "report.txt").write_text("artifact", encoding="utf-8") + self.output_files = ["report.txt"] + return _FakeResult(success=True, stdout="done\n") + if 'call_tool("compute", a=20, b=22)' in code: + total = self._invoke_tool("compute", a=20, b=22) + return _FakeResult(success=True, stdout=f"{total}\n") + return _FakeResult(success=False, stderr="sandbox boom") + + def snapshot(self) -> str: + return "snapshot" + + def restore(self, snapshot: Any) -> None: + self.restore_calls.append(snapshot) + + def get_output_files(self) -> list[str]: + return list(self.output_files) + + +class _FakeRuntime: + def __init__(self) -> None: + self.calls: list[tuple[Any, str]] = [] + + def execute(self, *, config: Any, code: str) -> list[Content]: + self.calls.append((config, code)) + return [Content.from_text("ok")] + + +class _FakeSandboxWithoutOutputListing(_FakeSandbox): + def get_output_files(self) -> list[str]: + return [] + + +class _FakeSandboxWithDelayedUnlistedOutput(_FakeSandboxWithoutOutputListing): + writer_threads: list[threading.Thread] = [] + + def run(self, code: str) -> _FakeResult: + if 'Path("/output/report.txt").write_text("artifact", encoding="utf-8")' in code: + if self.output_dir is None: + raise AssertionError("Expected output directory for delayed output test.") + + def _write_file() -> None: + time.sleep(0.15) + Path(self.output_dir, "report.txt").write_text("artifact", encoding="utf-8") + + writer_thread = threading.Thread(target=_write_file) + writer_thread.start() + self.writer_threads.append(writer_thread) + return _FakeResult(success=True) + + return super().run(code) + + +class _FakeSessionContext: + def __init__(self, *, tools: list[Any] | None = None) -> None: + self.options: dict[str, Any] = {} + if tools is not None: + self.options["tools"] = tools + self.instructions: list[tuple[str, str]] = [] + self.tools: list[tuple[str, list[Any]]] = [] + + def extend_instructions(self, source_id: str, instructions: str) -> None: + self.instructions.append((source_id, instructions)) + + def extend_tools(self, source_id: str, tools: list[Any]) -> None: + self.tools.append((source_id, tools)) + + +def _extract_execute_code_result(function_result: Content) -> Content: + assert function_result.type == "function_result" + assert function_result.exception is None, ( + f"execute_code raised {function_result.exception!r} with items={function_result.items!r}" + ) + + code_result = next( + (item for item in function_result.items or [] if item.type == "code_interpreter_tool_result"), + None, + ) + if code_result is not None: + return code_result + + text_outputs = [item for item in function_result.items or [] if item.type == "text"] + if text_outputs: + return Content.from_code_interpreter_tool_result(outputs=text_outputs) + + if function_result.result: + return Content.from_code_interpreter_tool_result(outputs=[Content.from_text(function_result.result)]) + + raise AssertionError(f"execute_code returned no usable outputs: {function_result.items!r}") + + +def _extract_text_output(result_content: Content) -> str: + code_result = _extract_execute_code_result(result_content) + text_output = next( + (item for item in code_result.outputs or [] if item.type == "text" and item.text is not None), None + ) + assert text_output is not None and text_output.text is not None, ( + f"Expected text output from execute_code, got {code_result.outputs!r}" + ) + return text_output.text + + +class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): + def __init__(self) -> None: + FunctionInvocationLayer.__init__(self) + BaseChatClient.__init__(self) + self.call_count = 0 + + def _inner_get_response( + self, + *, + messages: MutableSequence[Message], + stream: bool, + options: Mapping[str, Any], + **kwargs: Any, + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + if stream: + raise AssertionError("Streaming is not used in this integration test.") + + async def _get_response() -> ChatResponse: + self.call_count += 1 + + if self.call_count == 1: + return ChatResponse( + messages=Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="execute_code_call", + name="execute_code", + arguments={ + "code": 'total = call_tool("compute", a=20, b=22)\nprint(total)', + }, + ) + ], + ) + ) + + function_results = [ + content for message in messages for content in message.contents if content.type == "function_result" + ] + assert len(function_results) == 1 + + result_content = function_results[0] + assert result_content.call_id == "execute_code_call" + assert _extract_text_output(result_content) == "42\n" + + return ChatResponse(messages=Message(role="assistant", contents=["The sandbox returned 42."])) + + return _get_response() + + +def test_execute_code_tool_updates_approval_with_managed_tools() -> None: + execute_code = HyperlightExecuteCodeTool(tools=[compute], _registry=_FakeRuntime()) + assert execute_code.approval_mode == "never_require" + + execute_code.add_tools([dangerous_compute]) + assert execute_code.approval_mode == "always_require" + + +def test_execute_code_tool_replaces_tools_with_the_same_name() -> None: + execute_code = HyperlightExecuteCodeTool(tools=[compute], _registry=_FakeRuntime()) + + execute_code.add_tools(replacement_compute) + + tools = execute_code.get_tools() + assert len(tools) == 1 + assert tools[0] is replacement_compute + assert execute_code.approval_mode == "always_require" + + +def test_execute_code_tool_accepts_string_and_tuple_file_mounts_without_mode_flags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + shorthand_file = tmp_path / "notes.txt" + shorthand_file.write_text("hello", encoding="utf-8") + explicit_file = tmp_path / "data.json" + explicit_file.write_text('{"hello": "world"}', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + execute_code = HyperlightExecuteCodeTool(_registry=_FakeRuntime()) + execute_code.add_file_mounts("notes.txt") + execute_code.add_file_mounts((explicit_file, "data/data.json")) + + assert execute_code.get_file_mounts() == [ + FileMount(shorthand_file.resolve(), "/input/notes.txt"), + FileMount(explicit_file.resolve(), "/input/data/data.json"), + ] + + +async def test_execute_code_tool_populates_input_dir_with_workspace_and_file_mounts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + (workspace_root / "notes.txt").write_text("workspace note", encoding="utf-8") + + mounted_file = tmp_path / "mounted.txt" + mounted_file.write_text("hello from mount", encoding="utf-8") + + execute_code = HyperlightExecuteCodeTool( + workspace_root=workspace_root, + file_mounts=[FileMount(mounted_file, "data/input.txt")], + ) + result = await execute_code.invoke(arguments={"code": "None"}) + + assert result[0].type == "code_interpreter_tool_result" + assert _FakeSandbox.instances[0].input_dir is not None + + input_root = Path(_FakeSandbox.instances[0].input_dir) + assert (input_root / "notes.txt").read_text(encoding="utf-8") == "workspace note" + assert (input_root / "data" / "input.txt").read_text(encoding="utf-8") == "hello from mount" + + +def test_execute_code_tool_allowed_domains_use_structured_entries_and_replace_by_target() -> None: + execute_code = HyperlightExecuteCodeTool(_registry=_FakeRuntime()) + + execute_code.add_allowed_domains(["https://api.example.com/v1", ("github.com", "get")]) + execute_code.add_allowed_domains([ + AllowedDomain("api.example.com", ("post", "get")), + ("github.com", ["head", "get"]), + ]) + + assert execute_code.get_allowed_domains() == [ + AllowedDomain("api.example.com", ("GET", "POST")), + AllowedDomain("github.com", ("GET", "HEAD")), + ] + + +def test_execute_code_tool_description_contains_call_tool_guidance(tmp_path: Path) -> None: + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + (workspace_root / "notes.txt").write_text("hello", encoding="utf-8") + mount_file = tmp_path / "data.json" + mount_file.write_text('{"hello": "world"}', encoding="utf-8") + + execute_code = HyperlightExecuteCodeTool( + tools=[compute], + workspace_root=workspace_root, + file_mounts=[FileMount(str(mount_file), "data/data.json")], + allowed_domains=[AllowedDomain("https://api.example.com/v1", ("get", "post")), "github.com"], + _registry=_FakeRuntime(), + ) + + description = execute_code.description + + assert "call_tool(name, **kwargs)" in description + assert "compute" in description + assert "/input/data/data.json" in description + assert "/output" in description + assert "api.example.com" in description + assert "GET, POST" in description + assert "github.com" in description + + +async def test_execute_code_tool_executes_with_structured_content(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + + execute_code = HyperlightExecuteCodeTool( + tools=[compute], + file_mounts=[FileMount(Path(__file__), "fixtures/source.py")], + allowed_domains=[("api.example.com", "get")], + ) + + result = await execute_code.invoke(arguments={"code": "create-output"}) + + assert result[0].type == "code_interpreter_tool_result" + assert result[0].outputs is not None + assert result[0].outputs[0].type == "text" + assert result[0].outputs[0].text == "done\n" + assert any(item.type == "data" for item in result[0].outputs) + assert _FakeSandbox.instances[0].allowed_domains == [("api.example.com", ["GET"])] + assert "compute" in _FakeSandbox.instances[0].registered_tools + + +async def test_execute_code_tool_collects_output_files_without_backend_listing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandboxWithoutOutputListing) + + execute_code = HyperlightExecuteCodeTool( + file_mounts=[FileMount(Path(__file__), "fixtures/source.py")], + ) + result = await execute_code.invoke(arguments={"code": "create-output"}) + + assert result[0].type == "code_interpreter_tool_result" + assert result[0].outputs is not None + assert any( + item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs + ) + + +async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeSandboxWithDelayedUnlistedOutput.writer_threads.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandboxWithDelayedUnlistedOutput) + + execute_code = HyperlightExecuteCodeTool( + file_mounts=[FileMount(Path(__file__), "fixtures/source.py")], + ) + result = await execute_code.invoke( + arguments={"code": 'Path("/output/report.txt").write_text("artifact", encoding="utf-8")'} + ) + + for writer_thread in _FakeSandboxWithDelayedUnlistedOutput.writer_threads: + writer_thread.join() + + assert result[0].type == "code_interpreter_tool_result" + assert result[0].outputs is not None + assert any( + item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs + ) + + +async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + + execute_code = HyperlightExecuteCodeTool() + result = await execute_code.invoke(arguments={"code": "fail"}) + + assert result[0].type == "code_interpreter_tool_result" + assert result[0].outputs is not None + assert result[0].outputs[0].type == "error" + assert result[0].outputs[0].error_details == "sandbox boom" + + +async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_rejects_host_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeStrictNetworkSandbox: + instances: list[_FakeStrictNetworkSandbox] = [] + + def __init__( + self, + *, + input_dir: str | None = None, + output_dir: str | None = None, + backend: str = "wasm", + module: str | None = None, + module_path: str | None = None, + ) -> None: + del input_dir, output_dir, backend, module, module_path + self.allowed_domains: list[tuple[str, list[str] | None]] = [] + _FakeStrictNetworkSandbox.instances.append(self) + + def register_tool(self, name_or_tool: Any, callback: Any | None = None) -> None: + del name_or_tool, callback + + def allow_domain(self, target: str, methods: list[str] | None = None) -> None: + self.allowed_domains.append((target, methods)) + + def run(self, code: str) -> _FakeResult: + if code == "None" and any("://" not in target for target, _ in self.allowed_domains): + raise RuntimeError("invalid URL for network permission: ") + return _FakeResult(success=True) + + def snapshot(self) -> str: + return "snapshot" + + def restore(self, snapshot: Any) -> None: + del snapshot + + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeStrictNetworkSandbox) + + execute_code = HyperlightExecuteCodeTool(allowed_domains=[("127.0.0.1:8080", "get")]) + result = await execute_code.invoke(arguments={"code": "None"}) + + assert result[0].type == "code_interpreter_tool_result" + assert len(_FakeStrictNetworkSandbox.instances) == 2 + assert _FakeStrictNetworkSandbox.instances[0].allowed_domains == [("127.0.0.1:8080", ["GET"])] + assert _FakeStrictNetworkSandbox.instances[1].allowed_domains == [ + ("http://127.0.0.1:8080", ["GET"]), + ("https://127.0.0.1:8080", ["GET"]), + ] + + +def test_hyperlight_integration_runtime_skip_reason_reports_missing_hypervisor(monkeypatch: pytest.MonkeyPatch) -> None: + class _FakeNoHypervisorSandbox: + def __init__( + self, + *, + input_dir: str | None = None, + output_dir: str | None = None, + backend: str = "wasm", + module: str | None = None, + module_path: str | None = None, + ) -> None: + del input_dir, output_dir, backend, module, module_path + + def run(self, code: str) -> _FakeResult: + del code + raise RuntimeError("failed to build ProtoWasmSandbox: No Hypervisor was found for Sandbox") + + original_find_spec = importlib.util.find_spec + + def _fake_find_spec(name: str) -> object | None: + if name in {"hyperlight_sandbox", "python_guest"}: + return object() + return original_find_spec(name) + + monkeypatch.setattr(sys, "version_info", (3, 13, 0)) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec) + monkeypatch.setattr(importlib.metadata, "version", lambda _: "0.0.0") + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeNoHypervisorSandbox) + + assert _hyperlight_integration_runtime_skip_reason() == ( + "Hyperlight integration tests require a runner with a working Hyperlight hypervisor." + ) + + +async def test_provider_injects_run_scoped_execute_code_tool() -> None: + runtime = _FakeRuntime() + provider = HyperlightCodeActProvider(tools=[compute], _registry=runtime) + context = _FakeSessionContext(tools=[dangerous_compute]) + state: dict[str, Any] = {} + + await provider.before_run(agent=object(), session=None, context=context, state=state) + + assert context.options["tools"] == [dangerous_compute] + assert len(context.instructions) == 1 + assert len(context.tools) == 1 + + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + assert run_tool.approval_mode == "never_require" + assert [tool_obj.name for tool_obj in run_tool.get_tools()] == ["compute"] + assert "dangerous_compute" not in context.instructions[0][1] + assert "compute" not in context.instructions[0][1] + assert "Filesystem capabilities:" not in context.instructions[0][1] + assert state[provider.source_id]["tool_names"] == ["compute"] + assert state[provider.source_id]["approval_mode"] == "never_require" + json.dumps(state) + + provider.remove_tool("compute") + assert [tool_obj.name for tool_obj in run_tool.get_tools()] == ["compute"] + + +async def test_agent_runs_hyperlight_codeact_end_to_end_with_fake_sandbox(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + + client = _FakeCodeActChatClient() + provider = HyperlightCodeActProvider(tools=[compute]) + agent = Agent(client=client, context_providers=[provider]) + + response = await agent.run("Use the sandbox to add 20 and 22.") + + assert response.text == "The sandbox returned 42." + assert client.call_count == 2 + assert len(_FakeSandbox.instances) == 1 + assert "compute" in _FakeSandbox.instances[0].registered_tools + + +@skip_if_hyperlight_integration_tests_disabled +async def test_agent_runs_hyperlight_codeact_end_to_end_with_real_sandbox() -> None: + _skip_if_hyperlight_integration_runtime_disabled() + + client = _FakeCodeActChatClient() + provider = HyperlightCodeActProvider(tools=[compute]) + agent = Agent(client=client, context_providers=[provider]) + + response = await agent.run("Use the sandbox to add 20 and 22.") + + assert response.text == "The sandbox returned 42." + assert client.call_count == 2 + + +@skip_if_hyperlight_integration_tests_disabled +async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path) -> None: + _skip_if_hyperlight_integration_runtime_disabled() + + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + provider = HyperlightCodeActProvider(workspace_root=workspace_root) + + context = _FakeSessionContext() + state: dict[str, Any] = {} + await provider.before_run(agent=object(), session=None, context=context, state=state) + + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + + result = await run_tool.invoke( + arguments={ + "code": ( + 'payload = "hello from sandbox"\n' + "output_path = None\n" + 'for candidate in ("/output/result.txt",):\n' + " try:\n" + ' with open(candidate, "w", encoding="utf-8") as f:\n' + " f.write(payload)\n" + " except OSError:\n" + " continue\n" + " output_path = candidate\n" + " break\n" + 'assert output_path is not None, "output path unavailable"\n' + 'print("validated")\n' + ) + } + ) + + assert result[0].type == "code_interpreter_tool_result" + outputs = result[0].outputs or [] + error_outputs = [ + f"{item.message}: {item.error_details}" + for item in outputs + if item.type == "error" and item.error_details is not None + ] + assert not error_outputs, error_outputs + + text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None) + if text_output is not None: + assert text_output.text == "validated\n" + + file_output = next((item for item in outputs if item.type == "data"), None) + if file_output is not None: + assert file_output.uri is not None and file_output.uri.startswith("data:") + assert file_output.additional_properties["path"] in {"/output/result.txt", "/output/output/result.txt"} + + +@pytest.mark.integration +@skip_if_hyperlight_integration_tests_disabled +@pytest.mark.skipif(sys.platform == "win32", reason="Hyperlight WASM sandbox lacks encodings.idna on Windows") +async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None: + _skip_if_hyperlight_integration_runtime_disabled() + + provider = HyperlightCodeActProvider() + provider.add_allowed_domains("bing.com") + + context = _FakeSessionContext() + state: dict[str, Any] = {} + await provider.before_run(agent=object(), session=None, context=context, state=state) + + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + + result = await run_tool.invoke( + arguments={ + "code": ( + "import _socket\n\n" + 'addresses = _socket.getaddrinfo("bing.com", 80, _socket.AF_INET, _socket.SOCK_STREAM)\n' + 'assert addresses, "bing.com did not resolve"\n' + "last_error = None\n" + "for family, socktype, proto, _, sockaddr in addresses:\n" + " connection = None\n" + " try:\n" + " connection = _socket.socket(family, socktype, proto)\n" + " connection.settimeout(10)\n" + " connection.connect(sockaddr)\n" + ' print("pinged bing.com")\n' + " break\n" + " except OSError as exc:\n" + " last_error = exc\n" + " finally:\n" + " if connection is not None:\n" + " try:\n" + " connection.close()\n" + " except OSError:\n" + " pass\n" + "else:\n" + ' raise last_error or RuntimeError("unable to reach bing.com")\n' + ) + } + ) + + assert result[0].type == "code_interpreter_tool_result" + outputs = result[0].outputs or [] + error_outputs = [ + f"{item.message}: {item.error_details}" + for item in outputs + if item.type == "error" and item.error_details is not None + ] + assert not error_outputs, error_outputs + + text_output = next((item for item in outputs if item.type == "text" and item.text is not None), None) + if text_output is not None: + assert text_output.text == "pinged bing.com\n" + + +# --------------------------------------------------------------------------- +# Real-sandbox tests using shared (long-lived) fixture +# --------------------------------------------------------------------------- + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_runs_simple_code(restored_sandbox) -> None: + result = restored_sandbox.run('print("hello")') + assert result.success + assert "hello" in result.stdout + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None: + result = restored_sandbox.run( + 'import sys\nprint("out")\nprint("err", file=sys.stderr)' + ) + assert result.success + assert "out" in result.stdout + assert "err" in result.stderr + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_code_failure_returns_nonzero_exit(restored_sandbox) -> None: + result = restored_sandbox.run("raise ValueError('boom')") + assert not result.success + assert "boom" in result.stderr + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_snapshot_restore_keeps_sandbox_functional(restored_sandbox) -> None: + """Verify snapshot/restore cycle leaves the sandbox in a working state.""" + # Mutate the sandbox + result1 = restored_sandbox.run('print("before snapshot")') + assert result1.success + + # Take a snapshot and restore + snapshot = restored_sandbox.snapshot() + restored_sandbox.restore(snapshot) + + # Sandbox still works after restore + result2 = restored_sandbox.run('print("after restore")') + assert result2.success + assert "after restore" in result2.stdout + + +# --------------------------------------------------------------------------- +# Real-sandbox tests using fresh (short-lived) fixture +# --------------------------------------------------------------------------- + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_with_tool_registration_and_execution(fresh_sandbox) -> None: + """Verify that a sync host tool round-trips via call_tool in the real sandbox.""" + + def multiply(a: int, b: int) -> int: + return a * b + + fresh_sandbox.register_tool("multiply", multiply) + fresh_sandbox.run("None") + snapshot = fresh_sandbox.snapshot() + fresh_sandbox.restore(snapshot) + result = fresh_sandbox.run('result = call_tool("multiply", a=6, b=7)\nprint(result)') + assert result.success + assert "42" in result.stdout + + +@skip_if_hyperlight_integration_tests_disabled +async def test_sandbox_async_callback_round_trips_with_real_sandbox(fresh_sandbox) -> None: + """Confirm that _make_sandbox_callback (sync wrapper) works with real FFI.""" + sandbox_tool = FunctionTool( + func=compute, + name="compute", + description="Add two numbers", + ) + callback = execute_code_module._make_sandbox_callback(sandbox_tool) + + fresh_sandbox.register_tool("compute", callback) + fresh_sandbox.run("None") + snapshot = fresh_sandbox.snapshot() + fresh_sandbox.restore(snapshot) + result = fresh_sandbox.run('total = call_tool("compute", a=20, b=22)\nprint(total)') + assert result.success + assert "42" in result.stdout + + +@skip_if_hyperlight_integration_tests_disabled +async def test_output_dir_cleared_between_invocations() -> None: + """Verify stale output files don't leak across invocations (comment 23).""" + _skip_if_hyperlight_integration_runtime_disabled() + + provider = HyperlightCodeActProvider(workspace_root=Path(__file__).parent) + context = _FakeSessionContext() + state: dict[str, Any] = {} + await provider.before_run(agent=object(), session=None, context=context, state=state) + + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + + # First invocation: write a file + result1 = await run_tool.invoke( + arguments={ + "code": ( + 'with open("/output/stale.txt", "w") as f:\n' + ' f.write("first")\n' + 'print("wrote")\n' + ) + } + ) + assert result1[0].type == "code_interpreter_tool_result" + outputs1 = result1[0].outputs or [] + assert any( + item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") + for item in outputs1 + ), "First invocation should produce stale.txt" + + # Second invocation: no file writes + result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'}) + outputs2 = result2[0].outputs or [] + stale_files = [ + item + for item in outputs2 + if item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") + ] + assert not stale_files, "Stale output file leaked into second invocation" + + +@skip_if_hyperlight_integration_tests_disabled +async def test_run_code_does_not_block_event_loop() -> None: + """Verify _run_code uses asyncio.to_thread so the event loop stays responsive (comment 26).""" + _skip_if_hyperlight_integration_runtime_disabled() + + provider = HyperlightCodeActProvider() + context = _FakeSessionContext() + state: dict[str, Any] = {} + await provider.before_run(agent=object(), session=None, context=context, state=state) + + run_tool = context.tools[0][1][0] + assert isinstance(run_tool, HyperlightExecuteCodeTool) + + # Monkeypatch the registry.execute to block on an event, proving the event loop + # stays responsive while the worker thread is blocked. + release = threading.Event() + async_started = asyncio.Event() + loop = asyncio.get_running_loop() + original_execute = run_tool._registry.execute + + def _blocking_execute(*, config, code): + loop.call_soon_threadsafe(async_started.set) + release.wait(timeout=10) + return original_execute(config=config, code=code) + + run_tool._registry.execute = _blocking_execute # type: ignore[assignment] + + concurrent_ran = False + + async def _concurrent_task(): + nonlocal concurrent_ran + await async_started.wait() + concurrent_ran = True + release.set() + + code_task = asyncio.create_task( + run_tool.invoke(arguments={"code": 'print("done")\n'}) + ) + await _concurrent_task() + result = await code_task + + assert concurrent_ran, "Event loop was blocked during sandbox execution" + assert result[0].type == "code_interpreter_tool_result" diff --git a/python/pyproject.toml b/python/pyproject.toml index 84ee9ac470..a3876f34dd 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -82,6 +82,7 @@ agent-framework-foundry = { workspace = true } agent-framework-foundry-local = { workspace = true } agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } +agent-framework-hyperlight = { workspace = true } agent-framework-lab = { workspace = true } agent-framework-mem0 = { workspace = true } agent-framework-ollama = { workspace = true } diff --git a/python/samples/02-agents/context_providers/azure_ai_search/README.md b/python/samples/02-agents/context_providers/azure_ai_search/README.md index 9e5f6c03f2..2e32819003 100644 --- a/python/samples/02-agents/context_providers/azure_ai_search/README.md +++ b/python/samples/02-agents/context_providers/azure_ai_search/README.md @@ -8,7 +8,7 @@ This folder contains examples demonstrating how to use the Azure AI Search conte | File | Description | |------|-------------| -| [`search_context_agentic.py`](search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval with automatic query reformulation. Slightly slower with more token consumption for query planning. [Learn more](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) | +| [`search_context_agentic.py`](search_context_agentic.py) | **Agentic mode** (recommended for most scenarios): Uses Knowledge Bases in Azure AI Search for query planning and multi-hop reasoning. Provides more accurate results through intelligent retrieval with automatic query reformulation. Slightly slower with more token consumption for query planning. [Learn more](https://learn.microsoft.com/azure/search/agentic-retrieval-overview) | | [`search_context_semantic.py`](search_context_semantic.py) | **Semantic mode** (fast queries): Fast hybrid search combining vector and keyword search with semantic ranking. Returns raw search results as context. Best for scenarios where speed is critical and simple retrieval is sufficient. | ## Installation @@ -265,4 +265,4 @@ async with Agent( - [RAG with Azure AI Search](https://learn.microsoft.com/azure/search/retrieval-augmented-generation-overview) - [Semantic Search in Azure AI Search](https://learn.microsoft.com/azure/search/semantic-search-overview) - [Knowledge Bases in Azure AI Search](https://learn.microsoft.com/azure/search/knowledge-store-concept-intro) -- [Agentic Retrieval Blog Post](https://techcommunity.microsoft.com/blog/azure-ai-foundry-blog/foundry-iq-boost-response-relevance-by-36-with-agentic-retrieval/4470720) +- [Agentic Retrieval in Azure AI Search](https://learn.microsoft.com/azure/search/agentic-retrieval-overview) diff --git a/python/uv.lock b/python/uv.lock index 7755412101..a0090d1f75 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -45,6 +45,7 @@ members = [ "agent-framework-foundry-local", "agent-framework-gemini", "agent-framework-github-copilot", + "agent-framework-hyperlight", "agent-framework-lab", "agent-framework-mem0", "agent-framework-ollama", @@ -545,6 +546,25 @@ requires-dist = [ { name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" }, ] +[[package]] +name = "agent-framework-hyperlight" +version = "1.0.0a260409" +source = { editable = "packages/hyperlight" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "hyperlight-sandbox", specifier = ">=0.3.0,<0.4" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, + { name = "hyperlight-sandbox-python-guest", specifier = ">=0.3.0,<0.4" }, +] + [[package]] name = "agent-framework-lab" version = "1.0.0b260409" @@ -2725,6 +2745,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] +[[package]] +name = "hyperlight-sandbox" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/fe/ce88996ea3e3e05130d6f0e8cd2ffbe9ab9bf3d9448b7050d4b8d0802b0a/hyperlight_sandbox-0.3.0.tar.gz", hash = "sha256:00491ce267ffbdb206377c79b4afd86510177ad73f4daf2ef7fce02b54eaf801", size = 9251, upload-time = "2026-04-07T03:49:52.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/33/e6dcd6729308d13570ae2d3be0e476019a6f3fea387d7549bb1f77ce0408/hyperlight_sandbox-0.3.0-py3-none-any.whl", hash = "sha256:ba8e6779d64e9c187acd93456851ebafaed2f49380e5d132bc0906a4080d2217", size = 5723, upload-time = "2026-04-07T03:49:53.276Z" }, +] + +[[package]] +name = "hyperlight-sandbox-backend-wasm" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/91/c9d68cad7996fdd2f1facef1453156bdd8d52eefa976cc8c827c13029497/hyperlight_sandbox_backend_wasm-0.3.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:eda362f5f737b0823326290d7627c76ce0547a78e70f07f8c9d177e34622fc02", size = 3806454, upload-time = "2026-04-07T03:49:24.238Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/6b2399a1caf59dd19b635d99ee1add0c975af7bc3317f5d0f1f9c3f90aa0/hyperlight_sandbox_backend_wasm-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:79347b7ae94f2786691b04cb52130dabc5991e0c03b42a24bad8adc766832655", size = 3283951, upload-time = "2026-04-07T03:49:17.137Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/b380c34a0ce8d486a05adb66757f98cca029e1fb1c96b1c29be0d25d3882/hyperlight_sandbox_backend_wasm-0.3.0-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:aff9eec4803fb535a140298e2632529f4150fcf3c6ea3ff2ae4571572a836116", size = 3806601, upload-time = "2026-04-07T03:49:22.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5a/fb78cfd934e0523887b8d5b073b7b2aed3b545add21cda3aa95929ac1659/hyperlight_sandbox_backend_wasm-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:b6151704dd19862c9869b115752b4504b45d0b2eeb46aa9385a1a3b8be11cfa8", size = 3284164, upload-time = "2026-04-07T03:49:18.556Z" }, + { url = "https://files.pythonhosted.org/packages/21/bc/4e21f5c7ccd9307ac63a61c71b62a57ee4a9e6eec77fc72ff072907a21f5/hyperlight_sandbox_backend_wasm-0.3.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:cfd1d22ce221774d82a5174d268d56ff70fc1a23fb993a6491358b5d0ed169bf", size = 3802901, upload-time = "2026-04-07T03:49:19.845Z" }, + { url = "https://files.pythonhosted.org/packages/9a/41/646be9b0c7bb0f9192e45a77414673aa414eb316c92b5312efe6fb4ce802/hyperlight_sandbox_backend_wasm-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:229ab494a422f2de895a2a27ad6a6a2daed710ea062d7c213878bbe5f5b32fa7", size = 3281220, upload-time = "2026-04-07T03:49:21.368Z" }, + { url = "https://files.pythonhosted.org/packages/74/3a/f8ec4a41fffba4036dfc3cbddc3dfb6e87466b01afe1cb0a50cc6a0f0eed/hyperlight_sandbox_backend_wasm-0.3.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b91905ee2ddd36a78b0dd13b1a62be99a995a45121587c111692591e40b36912", size = 3802789, upload-time = "2026-04-07T03:49:15.614Z" }, + { url = "https://files.pythonhosted.org/packages/3c/62/dfa8c15102f9b8ec5c3b5ffb54b99d60c75e7a6e4d00540757656bc5a5d8/hyperlight_sandbox_backend_wasm-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:eff682761c3b86abfe7e0d523ea0e6d5c7e8299302917c53918743b82c9d1ea2", size = 3280501, upload-time = "2026-04-07T03:49:13.939Z" }, +] + +[[package]] +name = "hyperlight-sandbox-python-guest" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/6a/f182c4315d31a98dd3b82f9274638e3adb399779584af93c5087bb2f814f/hyperlight_sandbox_python_guest-0.3.0.tar.gz", hash = "sha256:b1de5d8e87375dc6bef744ecd7ae2a7f43d5f6b913b4e990e9872bd439c0b19e", size = 21554625, upload-time = "2026-04-07T03:49:42.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/8e/4cd754928464f56528645c7421ccbb3fcbe45ad2542f899712b0f2f2c0e1/hyperlight_sandbox_python_guest-0.3.0-py3-none-any.whl", hash = "sha256:3c55a7420666ad9a208893dbdf7ad1b5c8ad4f3a94b1a56e64979719c7ce95c1", size = 21716481, upload-time = "2026-04-07T03:49:39.885Z" }, +] + [[package]] name = "idna" version = "3.11" From c85d24da440ebe5266852f6356aecdadc41379c6 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:18:15 -0700 Subject: [PATCH 02/52] .NET: Fix declarative resume edge predicates to recognize both direct and PortableValue-wrapped forms after checkpoint restore (#5323) * Fix declarative workflows edge predicates after checkpoint restore * Update test names to make them clearer and more discoverable. * Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Kit/ActionExecutorResult.cs | 7 + .../ObjectModel/InvokeAzureAgentExecutor.cs | 6 +- .../ObjectModel/InvokeMcpToolExecutor.cs | 6 +- .../Kit/PortableValuePredicateTests.cs | 191 ++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs index 99d2e29f50..4bf2a12500 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Kit/ActionExecutorResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; + namespace Microsoft.Agents.AI.Workflows.Declarative.Kit; /// @@ -25,6 +27,11 @@ public sealed record class ActionExecutorResult internal static ActionExecutorResult ThrowIfNot(object? message) { + if (message is PortableValue portableValue && portableValue.IsType(out ActionExecutorResult? unwrapped)) + { + return unwrapped; + } + if (message is not ActionExecutorResult executorMessage) { throw new DeclarativeActionException($"Unexpected message type: {message?.GetType().Name ?? "(null)"} (Expected: {nameof(ActionExecutorResult)})"); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 24653af0f2..86efa6ec43 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -27,9 +27,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA public static string Resume(string id) => $"{id}_{nameof(Resume)}"; } - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); private AzureAgentUsage AgentUsage => Throw.IfNull(this.Model.Agent, $"{nameof(this.Model)}.{nameof(this.Model.Agent)}"); private AzureAgentInput? AgentInput => this.Model.Input; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index b1d9a44269..7540556f64 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -46,12 +46,14 @@ internal sealed class InvokeMcpToolExecutor( /// /// Determines if the message indicates external input is required. /// - public static bool RequiresInput(object? message) => message is ExternalInputRequest; + public static bool RequiresInput(object? message) => + message is ExternalInputRequest || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _)); /// /// Determines if the message indicates no external input is required. /// - public static bool RequiresNothing(object? message) => message is ActionExecutorResult; + public static bool RequiresNothing(object? message) => + message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out ActionExecutorResult? _)); /// protected override bool EmitResultEvent => false; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs new file mode 100644 index 0000000000..4ed50afb5a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Kit/PortableValuePredicateTests.cs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft. All rights reserved. + +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Declarative.Events; +using Microsoft.Agents.AI.Workflows.Declarative.Kit; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.Kit; + +/// +/// Tests that edge predicates correctly handle PortableValue-wrapped messages, +/// which occur after checkpoint restore (JSON round-trip). +/// +public sealed class PortableValuePredicateTests +{ + #region ActionExecutorResult.ThrowIfNot + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithDirectActionExecutorResult_ReturnsResult() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(result); + + // Assert + actual.Should().BeSameAs(result); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedActionExecutorResult_Unwraps() + { + // Arrange + ActionExecutorResult result = new("test-executor"); + PortableValue wrapped = new(result); + + // Act + ActionExecutorResult actual = ActionExecutorResult.ThrowIfNot(wrapped); + + // Assert + actual.ExecutorId.Should().Be("test-executor"); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNonActionExecutorResult_Throws() + { + // Arrange + object message = "not an ActionExecutorResult"; + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(message)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithNull_Throws() + { + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(null)); + } + + [Fact] + public void ActionExecutorResult_ThrowIfNot_WithPortableValueWrappedNonResult_Throws() + { + // Arrange + PortableValue wrapped = new("not an ActionExecutorResult"); + + // Act & Assert + Assert.Throws(() => ActionExecutorResult.ThrowIfNot(wrapped)); + } + + #endregion + + #region InvokeAzureAgentExecutor Predicates + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithDirectExternalInputRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(request).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresInput_WithActionExecutorResult_ReturnsFalse() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresInput(result).Should().BeFalse(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithDirectActionExecutorResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(result).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeAzureAgentExecutor_RequiresNothing_WithExternalInputRequest_ReturnsFalse() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + + // Act & Assert + InvokeAzureAgentExecutor.RequiresNothing(request).Should().BeFalse(); + } + + #endregion + + #region InvokeMcpToolExecutor Predicates + + [Fact] + public void InvokeMcpToolExecutor_RequiresInput_WithPortableValueWrappedRequest_ReturnsTrue() + { + // Arrange + ExternalInputRequest request = new("test prompt"); + PortableValue wrapped = new(request); + + // Act & Assert + InvokeMcpToolExecutor.RequiresInput(wrapped).Should().BeTrue(); + } + + [Fact] + public void InvokeMcpToolExecutor_RequiresNothing_WithPortableValueWrappedResult_ReturnsTrue() + { + // Arrange + ActionExecutorResult result = new("test"); + PortableValue wrapped = new(result); + + // Act & Assert + InvokeMcpToolExecutor.RequiresNothing(wrapped).Should().BeTrue(); + } + + #endregion + + #region QuestionExecutor.IsComplete + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NullResult_ReturnsTrue() + { + // Arrange - result with null Result property means "complete" + ActionExecutorResult result = new("test", result: null); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeTrue(); + } + + [Fact] + public void QuestionExecutor_IsComplete_WithPortableValueWrappedResult_NonNullResult_ReturnsFalse() + { + // Arrange - result with non-null Result property means "not complete" + ActionExecutorResult result = new("test", result: true); + PortableValue wrapped = new(result); + + // Act & Assert + QuestionExecutor.IsComplete(wrapped).Should().BeFalse(); + } + + #endregion +} From 52303a8d07e8f9f2c3f056d969d99a9062c06219 Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Fri, 17 Apr 2026 09:55:03 -0700 Subject: [PATCH 03/52] .NET: Add Code Interpreter container file download samples (#5014) * Add Code Interpreter container file download samples (#3081) - Add Agent_OpenAI_Step06_CodeInterpreterFileDownload (Public OpenAI) - Add Agent_Step24_CodeInterpreterFileDownload (Microsoft Foundry) - Both samples demonstrate downloading cfile_/cntr_ container files via ContainerClient instead of the standard Files API - Update solution file and parent READMEs * Address review feedback: flatten nested foreach loops using SelectMany Addresses https://github.com/microsoft/agent-framework/pull/5014#discussion_r3046908449 and https://github.com/microsoft/agent-framework/pull/5014#discussion_r3046920209 --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: rogerbarreto --- dotnet/agent-framework-dotnet.slnx | 2 + ..._Step06_CodeInterpreterFileDownload.csproj | 15 +++ .../Program.cs | 89 ++++++++++++++++++ .../README.md | 51 +++++++++++ .../02-agents/AgentWithOpenAI/README.md | 3 +- ..._Step24_CodeInterpreterFileDownload.csproj | 19 ++++ .../Program.cs | 91 +++++++++++++++++++ .../README.md | 56 ++++++++++++ .../02-agents/AgentsWithFoundry/README.md | 1 + 9 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj create mode 100644 dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 00a1882018..69c5698a88 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -152,6 +152,7 @@ + @@ -173,6 +174,7 @@ + diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj new file mode 100644 index 0000000000..06380e8016 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Agent_OpenAI_Step06_CodeInterpreterFileDownload.csproj @@ -0,0 +1,15 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs new file mode 100644 index 0000000000..c01ff4304e --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/Program.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to download files generated by Code Interpreter using the Containers API. +// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be +// downloaded via the standard Files API. Use ContainerClient instead. + +#pragma warning disable OPENAI001 + +using System.ClientModel; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI; +using OpenAI.Containers; +using OpenAI.Responses; + +string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +string model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; + +var openAIClient = new OpenAIClient(new ApiKeyCredential(apiKey)); + +// Create an agent with Code Interpreter tool enabled +AIAgent agent = openAIClient + .GetResponsesClient() + .AsAIAgent( + model: model, + instructions: "You are a helpful assistant that can generate files using code.", + name: "CodeInterpreterAgent", + tools: [new HostedCodeInterpreterTool()]); + +// Ask the agent to generate a file +AgentResponse response = await agent.RunAsync( + "Create a CSV file with the multiplication times tables from 1 to 12. Include headers."); + +// Display the text response +foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType()) +{ + Console.WriteLine(textContent.Text); +} + +// Extract container file citations from response annotations and download +ContainerClient containerClient = openAIClient.GetContainerClient(); + +HashSet downloadedFiles = []; +bool foundContainerFiles = false; + +foreach (AIContent content in response.Messages.SelectMany(x => x.Contents)) +{ + if (content.Annotations is null) + { + continue; + } + + foreach (AIAnnotation annotation in content.Annotations) + { + // Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation + if (annotation is CitationAnnotation citation + && citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation) + { + foundContainerFiles = true; + + // Deduplicate by container+file ID in case the same file is cited multiple times + string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}"; + if (!downloadedFiles.Add(key)) + { + continue; + } + + Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}"); + Console.WriteLine($" Container ID: {containerCitation.ContainerId}"); + Console.WriteLine($" File ID: {containerCitation.FileId}"); + + BinaryData fileData = await containerClient.DownloadContainerFileAsync( + containerCitation.ContainerId, + containerCitation.FileId); + + // Sanitize filename to prevent path traversal + string safeFilename = Path.GetFileName(containerCitation.Filename); + string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename); + await File.WriteAllBytesAsync(outputPath, fileData.ToArray()); + Console.WriteLine($" Saved to: {outputPath}"); + } + } +} + +if (!foundContainerFiles) +{ + Console.WriteLine("\nNo container file citations found in the response."); + Console.WriteLine("The model may not have generated a downloadable file for this prompt."); +} diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md new file mode 100644 index 0000000000..4ba457d0f8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/README.md @@ -0,0 +1,51 @@ +# Code Interpreter File Download (OpenAI) + +This sample demonstrates how to download files generated by Code Interpreter when using the OpenAI Responses API. + +## What this sample demonstrates + +- Creating an agent with Code Interpreter tool using `ResponsesClient.AsAIAgent()` +- Generating files through Code Interpreter (e.g., CSV, Excel, images) +- Extracting container file citations from agent response annotations +- Downloading container files using the `ContainerClient` API + +## Container files vs regular files + +When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID. + +These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** (`GetContainerClient`) to download them: + +```csharp +// ❌ This does NOT work for container files +var filesClient = openAIClient.GetOpenAIFileClient(); +await filesClient.DownloadFileAsync("cfile_..."); // Returns 404 + +// ✅ Use ContainerClient instead +var containerClient = openAIClient.GetContainerClient(); +await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_..."); +``` + +The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`. + +## Prerequisites + +- .NET 10 SDK or later +- OpenAI API key with access to a model that supports Code Interpreter + +Set the following environment variables: + +```powershell +$env:OPENAI_API_KEY="sk-..." +$env:OPENAI_CHAT_MODEL_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +```powershell +dotnet run +``` + +## See also + +- [Code Interpreter File Download with Foundry](../../../02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/) — same scenario using Microsoft Foundry +- [Code Interpreter](../../../02-agents/AgentsWithFoundry/Agent_Step14_CodeInterpreter/) — Code Interpreter without file download diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/README.md b/dotnet/samples/02-agents/AgentWithOpenAI/README.md index 74a44600bf..78955a72af 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/README.md +++ b/dotnet/samples/02-agents/AgentWithOpenAI/README.md @@ -14,4 +14,5 @@ Agent Framework provides additional support to allow OpenAI developers to use th |[Using Reasoning Capabilities](./Agent_OpenAI_Step02_Reasoning/)|This sample demonstrates how to create an AI agent with reasoning capabilities using OpenAI's reasoning models and response types.| |[Creating an Agent from a ChatClient](./Agent_OpenAI_Step03_CreateFromChatClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Chat.ChatClient instance using OpenAIChatClientAgent.| |[Creating an Agent from an OpenAIResponseClient](./Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/)|This sample demonstrates how to create an AI agent directly from an OpenAI.Responses.OpenAIResponseClient instance using OpenAIResponseClientAgent.| -|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.| \ No newline at end of file +|[Managing Conversation State](./Agent_OpenAI_Step05_Conversation/)|This sample demonstrates how to maintain conversation state across multiple turns using the AgentSession for context continuity.| +|[Code Interpreter File Download](./Agent_OpenAI_Step06_CodeInterpreterFileDownload/)|This sample demonstrates how to download files generated by Code Interpreter using the Containers API (`cfile_`/`cntr_` IDs).| \ No newline at end of file diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj new file mode 100644 index 0000000000..129c9026a2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Agent_Step24_CodeInterpreterFileDownload.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs new file mode 100644 index 0000000000..79fac0d5d4 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to download files generated by Code Interpreter using Microsoft Foundry. +// Code Interpreter generates files inside containers (cfile_ / cntr_ IDs) which cannot be +// downloaded via the standard Files API. Use ContainerClient from the project's OpenAI client instead. + +#pragma warning disable OPENAI001 + +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-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 aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); + +// Create an agent with Code Interpreter tool enabled +AIAgent agent = aiProjectClient.AsAIAgent( + deploymentName, + instructions: "You are a helpful assistant that can generate files using code.", + name: "CodeInterpreterAgent", + tools: [new HostedCodeInterpreterTool()]); + +// Ask the agent to generate a file +AgentResponse response = await agent.RunAsync( + "Create a CSV file with the multiplication times tables from 1 to 12. Include headers."); + +// Display the text response +foreach (TextContent textContent in response.Messages.SelectMany(x => x.Contents).OfType()) +{ + Console.WriteLine(textContent.Text); +} + +// Extract container file citations from response annotations and download. +// AIProjectClient.GetProjectOpenAIClient() returns a ProjectOpenAIClient (inherits from OpenAI.OpenAIClient) +// which supports GetContainerClient(), unlike AzureOpenAIClient which does not. +var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient(); + +HashSet downloadedFiles = []; +bool foundContainerFiles = false; + +foreach (AIContent content in response.Messages.SelectMany(x => x.Contents)) +{ + if (content.Annotations is null) + { + continue; + } + + foreach (AIAnnotation annotation in content.Annotations) + { + // Container files from Code Interpreter have ContainerFileCitationMessageAnnotation as raw representation + if (annotation is CitationAnnotation citation + && citation.RawRepresentation is ContainerFileCitationMessageAnnotation containerCitation) + { + foundContainerFiles = true; + + // Deduplicate by container+file ID in case the same file is cited multiple times + string key = $"{containerCitation.ContainerId}/{containerCitation.FileId}"; + if (!downloadedFiles.Add(key)) + { + continue; + } + + Console.WriteLine($"\nDownloading container file: {containerCitation.Filename}"); + Console.WriteLine($" Container ID: {containerCitation.ContainerId}"); + Console.WriteLine($" File ID: {containerCitation.FileId}"); + + BinaryData fileData = await containerClient.DownloadContainerFileAsync( + containerCitation.ContainerId, + containerCitation.FileId); + + // Sanitize filename to prevent path traversal + string safeFilename = Path.GetFileName(containerCitation.Filename); + string outputPath = Path.Combine(Directory.GetCurrentDirectory(), safeFilename); + await File.WriteAllBytesAsync(outputPath, fileData.ToArray()); + Console.WriteLine($" Saved to: {outputPath}"); + } + } +} + +if (!foundContainerFiles) +{ + Console.WriteLine("\nNo container file citations found in the response."); + Console.WriteLine("The model may not have generated a downloadable file for this prompt."); +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md new file mode 100644 index 0000000000..4d50b98ca8 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step24_CodeInterpreterFileDownload/README.md @@ -0,0 +1,56 @@ +# Code Interpreter File Download (Microsoft Foundry) + +This sample demonstrates how to download files generated by Code Interpreter when using Microsoft Foundry. + +## What this sample demonstrates + +- Creating an agent with Code Interpreter tool using `AIProjectClient.AsAIAgent()` +- Generating files through Code Interpreter (e.g., CSV, Excel, images) +- Extracting container file citations from agent response annotations +- Downloading container files using the `ContainerClient` via `AIProjectClient.GetProjectOpenAIClient()` + +## Container files vs regular files + +When Code Interpreter generates a file, the file is stored inside a **container** with a `cntr_` prefixed ID. The file itself gets a `cfile_` prefixed ID. + +These container files **cannot** be downloaded using the standard Files API (`GetOpenAIFileClient`), which returns 404 for `cfile_` IDs. Instead, you must use the **Containers API** to download them. + +### Getting the ContainerClient with Foundry + +`AzureOpenAIClient.GetContainerClient()` is not supported and throws `InvalidOperationException`. Instead, use the project's OpenAI client which inherits directly from `OpenAI.OpenAIClient`: + +```csharp +// ❌ AzureOpenAIClient does not support ContainerClient +var azureClient = new AzureOpenAIClient(endpoint, credential); +azureClient.GetContainerClient(); // Throws InvalidOperationException + +// ✅ Use AIProjectClient's project OpenAI client +var containerClient = aiProjectClient.GetProjectOpenAIClient().GetContainerClient(); +await containerClient.DownloadContainerFileAsync("cntr_...", "cfile_..."); +``` + +The container ID and file ID are available from the `ContainerFileCitationMessageAnnotation` annotation in the response, accessible via `CitationAnnotation.RawRepresentation`. + +## Prerequisites + +- .NET 10 SDK or later +- Microsoft Foundry service endpoint and deployment configured +- Azure CLI installed and authenticated (`az login`) + +Set the following environment variables: + +```powershell +$env:AZURE_AI_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:AZURE_AI_MODEL_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini +``` + +## Run the sample + +```powershell +dotnet run +``` + +## See also + +- [Code Interpreter File Download with OpenAI](../../../02-agents/AgentWithOpenAI/Agent_OpenAI_Step06_CodeInterpreterFileDownload/) — same scenario using Public OpenAI +- [Code Interpreter](../Agent_Step14_CodeInterpreter/) — Code Interpreter without file download diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/README.md index c65cb24acd..74160ec2ec 100644 --- a/dotnet/samples/02-agents/AgentsWithFoundry/README.md +++ b/dotnet/samples/02-agents/AgentsWithFoundry/README.md @@ -72,6 +72,7 @@ Some samples require extra tool-specific environment variables. See each sample | [Web search](./Agent_Step21_WebSearch/) | Web search tool | | [Memory search](./Agent_Step22_MemorySearch/) | Memory search tool | | [Local MCP](./Agent_Step23_LocalMCP/) | Local MCP client with HTTP transport | +| [Code interpreter file download](./Agent_Step24_CodeInterpreterFileDownload/) | Download container files generated by code interpreter | ## Running the samples From 5777ed26e62e721375f78c404b8df1dfbc322560 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 17 Apr 2026 16:15:27 -0400 Subject: [PATCH 04/52] .NET: fix: Add session support for Handoff-hosted Agents (#5280) * fix: Add session support for Handoff-hosted Agents In order to better support using `Workflows` hosted as `AIAgents` inside of Handoff workflows, we need to make proper use of AgentSession. This causes potential issues around checkpointing and making sure that we properly compute only the new incoming messages for each agent invocation. * fix: AgentSession checkpointing using AIAgent's Serialize/Deserialize methods We cannot rely on implicit serialization through `HandoffHostState` because we are missing type information. * fix: Thread safety issue in `MultiPartyConversation.AllMessages` * fix: Enable unwrapping of FunctionResultContent when ExternalRequest was wrapped into FunctionCallContent --- .../AIAgentsAbstractionsExtensions.cs | 2 +- .../Specialized/HandoffAgentExecutor.cs | 363 ++++++++---------- .../Specialized/HandoffEndExecutor.cs | 33 +- .../Specialized/HandoffMessagesFilter.cs | 143 +++++++ .../Specialized/HandoffStartExecutor.cs | 49 ++- .../Specialized/HandoffState.cs | 4 - .../Specialized/MultiPartyConversation.cs | 56 +++ .../WorkflowHostingExtensions.cs | 2 +- .../WorkflowSession.cs | 37 +- .../HandoffAgentExecutorTests.cs | 244 +++++++++--- .../Sample/12_HandOff_HostAsAgent.cs | 1 + .../SampleSmokeTest.cs | 81 +++- .../TestRunContext.cs | 12 +- 13 files changed, 710 insertions(+), 317 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/MultiPartyConversation.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs index 165de39855..8c94f4aa85 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs @@ -48,7 +48,7 @@ internal static class AIAgentsAbstractionsExtensions /// any that have a different from to /// . /// - public static List? ChangeAssistantToUserForOtherParticipants(this List messages, string targetAgentName) + public static List? ChangeAssistantToUserForOtherParticipants(this IEnumerable messages, string targetAgentName) { List? roleChanged = null; foreach (var m in messages) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index eac2eb5687..d9acab96d5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -6,6 +6,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -31,140 +32,6 @@ internal sealed class HandoffAgentExecutorOptions public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly; } -[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] -internal sealed class HandoffMessagesFilter -{ - private readonly HandoffToolCallFilteringBehavior _filteringBehavior; - - public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior) - { - this._filteringBehavior = filteringBehavior; - } - - [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] - internal static bool IsHandoffFunctionName(string name) - { - return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); - } - - public IEnumerable FilterMessages(List messages) - { - if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None) - { - return messages; - } - - Dictionary filteringCandidates = new(); - List filteredMessages = []; - HashSet messagesToRemove = []; - - bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; - foreach (ChatMessage unfilteredMessage in messages) - { - ChatMessage filteredMessage = unfilteredMessage.Clone(); - - // .Clone() is shallow, so we cannot modify the contents of the cloned message in place. - List contents = []; - contents.Capacity = unfilteredMessage.Contents?.Count ?? 0; - filteredMessage.Contents = contents; - - // Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls - // originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result) - // FunctionCallContent. - if (unfilteredMessage.Role != ChatRole.Tool) - { - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) - { - AIContent content = unfilteredMessage.Contents[i]; - if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name))) - { - filteredMessage.Contents.Add(content); - - // Track non-handoff function calls so their tool results are preserved in HandoffOnly mode - if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc) - { - filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId) - { - IsHandoffFunction = false, - }; - } - } - else if (filterHandoffOnly) - { - if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState)) - { - filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId) - { - IsHandoffFunction = true, - }; - } - else - { - candidateState.IsHandoffFunction = true; - (int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; - ChatMessage messageToFilter = filteredMessages[messageIndex]; - messageToFilter.Contents.RemoveAt(contentIndex); - if (messageToFilter.Contents.Count == 0) - { - messagesToRemove.Add(messageIndex); - } - } - } - else - { - // All mode: strip all FunctionCallContent - } - } - } - else - { - if (!filterHandoffOnly) - { - continue; - } - - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) - { - AIContent content = unfilteredMessage.Contents[i]; - if (content is not FunctionResultContent frc - || (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState) - && candidateState.IsHandoffFunction is false)) - { - // Either this is not a function result content, so we should let it through, or it is a FRC that - // we know is not related to a handoff call. In either case, we should include it. - filteredMessage.Contents.Add(content); - } - else if (candidateState is null) - { - // We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later - filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) - { - FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count), - }; - } - // else we have seen the corresponding function call and it is a handoff, so we should filter it out. - } - } - - if (filteredMessage.Contents.Count > 0) - { - filteredMessages.Add(filteredMessage); - } - } - - return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index)); - } - - private class FilterCandidateState(string callId) - { - public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; } - - public string CallId => callId; - - public bool? IsHandoffFunction { get; set; } - } -} - internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId) { public AgentResponse Response => agentResponse; @@ -175,19 +42,31 @@ internal struct AgentInvocationResult(AgentResponse agentResponse, string? hando public bool IsHandoffRequested => this.HandoffTargetId != null; } -internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List FilteredIncomingMessages, List TurnMessages) +internal record HandoffAgentHostState( + HandoffState? IncomingState, + int ConversationBookmark) { - public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId) - { - if (this.CurrentTurnState == null) - { - throw new InvalidOperationException("Cannot create a handoff request: Out of turn."); - } + [MemberNotNullWhen(true, nameof(IncomingState))] + [JsonIgnore] + public bool IsTakingTurn => this.IncomingState != null; +} - IEnumerable allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages]; +internal sealed record StateRef(string Key, string? ScopeName) +{ + public ValueTask InvokeWithStateAsync(Func> invocation, + IWorkflowContext context, + CancellationToken cancellationToken) + => context.InvokeWithStateAsync(invocation, this.Key, this.ScopeName, cancellationToken); - return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId); - } + public ValueTask InvokeWithStateAsync(Func invocation, + IWorkflowContext context, + CancellationToken cancellationToken) + => context.InvokeWithStateAsync( + async (state, ctx, ct) => + { + await invocation(state, ctx, ct).ConfigureAwait(false); + return state; + }, this.Key, this.ScopeName, cancellationToken); } /// Executor used to represent an agent in a handoffs workflow, responding to events. @@ -208,7 +87,13 @@ internal sealed class HandoffAgentExecutor : private readonly HashSet _handoffFunctionNames = []; private readonly Dictionary _handoffFunctionToAgentId = []; - private static HandoffAgentHostState InitialStateFactory() => new(null, [], []); + private readonly StateRef _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey, + HandoffConstants.HandoffSharedStateScope); + + internal const string AgentSessionKey = nameof(AgentSession); + private AgentSession? _session; + + private static HandoffAgentHostState InitialStateFactory() => new(null, 0); public HandoffAgentExecutor(AIAgent agent, HashSet handoffs, HandoffAgentExecutorOptions options) : base(IdFor(agent), InitialStateFactory) @@ -291,13 +176,18 @@ internal sealed class HandoffAgentExecutor : // resumes can be processed in one invocation. return this.InvokeWithStateAsync((state, ctx, ct) => { - state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response]) + if (!state.IsTakingTurn) + { + throw new InvalidOperationException("Cannot process user responses when not taking a turn in Handoff Orchestration."); + } + + ChatMessage userMessage = new(ChatRole.User, [response]) { CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), - }); + }; - return this.ContinueTurnAsync(state, ctx, ct); + return this.ContinueTurnAsync(state, [userMessage], ctx, ct); }, context, skipCache: false, cancellationToken); } @@ -315,24 +205,44 @@ internal sealed class HandoffAgentExecutor : // resumes can be processed in one invocation. return this.InvokeWithStateAsync((state, ctx, ct) => { - state.TurnMessages.Add( - new ChatMessage(ChatRole.Tool, [result]) - { - AuthorName = this._agent.Name ?? this._agent.Id, - CreatedAt = DateTimeOffset.UtcNow, - MessageId = Guid.NewGuid().ToString("N"), - }); + if (!state.IsTakingTurn) + { + throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration."); + } - return this.ContinueTurnAsync(state, ctx, ct); + ChatMessage toolMessage = new(ChatRole.Tool, [result]) + { + AuthorName = this._agent.Name ?? this._agent.Id, + CreatedAt = DateTimeOffset.UtcNow, + MessageId = Guid.NewGuid().ToString("N"), + }; + + return this.ContinueTurnAsync(state, [toolMessage], ctx, ct); }, context, skipCache: false, cancellationToken); } - private async ValueTask ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + private async ValueTask ContinueTurnAsync(HandoffAgentHostState state, List incomingMessages, IWorkflowContext context, CancellationToken cancellationToken, bool skipAddIncoming = false) { - List? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + if (!state.IsTakingTurn) + { + throw new InvalidOperationException("Cannot process user responses in when not taking a turn in Handoff Orchestration."); + } - bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents); - AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken) + // If a handoff was invoked by a previous agent, filter out the handoff function call and tool result messages + // before sending to the underlying agent. These are internal workflow mechanics that confuse the target model + // into ignoring the original user question. + // + // This will not filter out tool responses and approval responses that are part of this agent's turn, which is + // the expected behavior since those are part of the agent's reasoning process. + HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior); + IEnumerable messagesForAgent = state.IncomingState.RequestedHandoffTargetAgentId is not null + ? handoffMessagesFilter.FilterMessages(incomingMessages) + : incomingMessages; + + List? roleChanges = messagesForAgent.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id); + + bool emitUpdateEvents = state.IncomingState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents); + AgentInvocationResult result = await this.InvokeAgentAsync(messagesForAgent, context, emitUpdateEvents, cancellationToken) .ConfigureAwait(false); if (this.HasOutstandingRequests && result.IsHandoffRequested) @@ -342,20 +252,40 @@ internal sealed class HandoffAgentExecutor : roleChanges.ResetUserToAssistantForChangedRoles(); + int newConversationBookmark = state.ConversationBookmark; + await this._sharedStateRef.InvokeWithStateAsync( + (sharedState, ctx, ct) => + { + if (sharedState == null) + { + throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized."); + } + + if (!skipAddIncoming) + { + sharedState.Conversation.AddMessages(incomingMessages); + } + + newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages); + + return new ValueTask(); + }, + context, + cancellationToken).ConfigureAwait(false); + // We send on the HandoffState even if handoff is not requested because we might be terminating the processing, but this only // happens if we have no outstanding requests. if (!this.HasOutstandingRequests) { - HandoffState outgoingState = state.PrepareHandoff(result, this._agent.Id); + HandoffState outgoingState = new(state.IncomingState.TurnToken, result.HandoffTargetId, this._agent.Id); await context.SendMessageAsync(outgoingState, cancellationToken).ConfigureAwait(false); - // reset the state for the next handoff (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which - // can be a bit confusing.) - return null; + // reset the state for the next handoff, making sure to keep track of the conversation bookmark, and avoid resetting the + // agent session. (return-to-current is modeled as a new handoff turn, as opposed to "HITL", which can be a bit confusing.) + return state with { IncomingState = null, ConversationBookmark = newConversationBookmark }; } - state.TurnMessages.AddRange(result.Response.Messages); return state; } @@ -363,28 +293,36 @@ internal sealed class HandoffAgentExecutor : { return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken); - ValueTask InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) + async ValueTask InvokeContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken) { // Check that we are not getting this message while in the middle of a turn - if (state.CurrentTurnState != null) + if (state.IsTakingTurn) { throw new InvalidOperationException("Cannot have multiple simultaneous conversations in Handoff Orchestration."); } - // If a handoff was invoked by a previous agent, filter out the handoff function - // call and tool result messages before sending to the underlying agent. These - // are internal workflow mechanics that confuse the target model into ignoring the - // original user question. - HandoffMessagesFilter handoffMessagesFilter = new(this._options.ToolCallFilteringBehavior); - IEnumerable messagesForAgent = message.RequestedHandoffTargetAgentId is not null - ? handoffMessagesFilter.FilterMessages(message.Messages) - : message.Messages; + IEnumerable newConversationMessages = []; + int newConversationBookmark = 0; - // This works because the runtime guarantees that a given executor instance will process messages serially, - // though there is no global cross-executor ordering guarantee (and in turn, no canonical message delivery order) - state = new(message, messagesForAgent.ToList(), []); + await this._sharedStateRef.InvokeWithStateAsync( + (sharedState, ctx, ct) => + { + if (sharedState == null) + { + throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized."); + } - return this.ContinueTurnAsync(state, context, cancellationToken); + (newConversationMessages, newConversationBookmark) = sharedState.Conversation.CollectNewMessages(state.ConversationBookmark); + + return new ValueTask(); + }, + context, + cancellationToken).ConfigureAwait(false); + + state = state with { IncomingState = message, ConversationBookmark = newConversationBookmark }; + + return await this.ContinueTurnAsync(state, newConversationMessages.ToList(), context, cancellationToken, skipAddIncoming: true) + .ConfigureAwait(false); } } @@ -395,18 +333,35 @@ internal sealed class HandoffAgentExecutor : { Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task agentSessionTask = CheckpointAgentSessionAsync(); Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask(); - await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false); + await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, agentSessionTask, baseTask).ConfigureAwait(false); + + async Task CheckpointAgentSessionAsync() + { + JsonElement? sessionState = this._session is not null ? await this._agent.SerializeSessionAsync(this._session, cancellationToken: cancellationToken).ConfigureAwait(false) : null; + await context.QueueStateUpdateAsync(AgentSessionKey, sessionState, cancellationToken: cancellationToken).ConfigureAwait(false); + } } protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default) { Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask; + Task agentSessionTask = RestoreAgentSessionAsync(); - await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false); + await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask, agentSessionTask).ConfigureAwait(false); await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false); + + async Task RestoreAgentSessionAsync() + { + JsonElement? sessionState = await context.ReadStateAsync(AgentSessionKey, cancellationToken: cancellationToken).ConfigureAwait(false); + if (sessionState.HasValue) + { + this._session = await this._agent.DeserializeSessionAsync(sessionState.Value, cancellationToken: cancellationToken).ConfigureAwait(false); + } + } } private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true) || (this._functionCallHandler?.HasPendingRequests == true); @@ -417,31 +372,43 @@ internal sealed class HandoffAgentExecutor : AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler); - IAsyncEnumerable agentStream = this._agent.RunStreamingAsync( - messages, - options: this._agentOptions, - cancellationToken: cancellationToken); - string? requestedHandoff = null; List updates = []; List candidateRequests = []; - await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) - { - await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); - - bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + await this.InvokeWithStateAsync( + async (state, ctx, ct) => { - bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); - if (isHandoffRequest) + this._session ??= await this._agent.CreateSessionAsync(ct).ConfigureAwait(false); + + IAsyncEnumerable agentStream = + this._agent.RunStreamingAsync(messages, + this._session, + options: this._agentOptions, + cancellationToken: ct); + + await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) { - candidateRequests.Add(candidateHandoffRequest); + await AddUpdateAsync(update, ct).ConfigureAwait(false); + + collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); + + bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + { + bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + if (isHandoffRequest) + { + candidateRequests.Add(candidateHandoffRequest); + } + + return !isHandoffRequest; + } } - return !isHandoffRequest; - } - } + return state; + }, + context, + cancellationToken: cancellationToken).ConfigureAwait(false); if (candidateRequests.Count > 1) { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs index 0ba8fc3501..c9c75b91c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffEndExecutor.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -12,23 +13,33 @@ internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(Execu { public const string ExecutorId = "HandoffEnd"; + private readonly StateRef _sharedStateRef = new(HandoffConstants.HandoffSharedStateKey, + HandoffConstants.HandoffSharedStateScope); + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) => - protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) => - this.HandleAsync(handoff, context, cancellationToken))) + protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler( + (handoff, context, cancellationToken) => this.HandleAsync(handoff, context, cancellationToken))) .YieldsOutput>(); private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken) { - if (returnToPrevious) - { - await context.QueueStateUpdateAsync(HandoffConstants.PreviousAgentTrackerKey, - handoff.PreviousAgentId, - HandoffConstants.PreviousAgentTrackerScope, - cancellationToken) - .ConfigureAwait(false); - } + await this._sharedStateRef.InvokeWithStateAsync( + async (HandoffSharedState? sharedState, IWorkflowContext context, CancellationToken cancellationToken) => + { + if (sharedState == null) + { + throw new InvalidOperationException("Handoff Orchestration shared state was not properly initialized."); + } - await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false); + if (returnToPrevious) + { + sharedState.PreviousAgentId = handoff.PreviousAgentId; + } + + await context.YieldOutputAsync(sharedState.Conversation.CloneAllMessages(), cancellationToken).ConfigureAwait(false); + + return sharedState; + }, context, cancellationToken).ConfigureAwait(false); } public ValueTask ResetAsync() => default; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs new file mode 100644 index 0000000000..7bc178c2d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] +internal sealed class HandoffMessagesFilter +{ + private readonly HandoffToolCallFilteringBehavior _filteringBehavior; + + public HandoffMessagesFilter(HandoffToolCallFilteringBehavior filteringBehavior) + { + this._filteringBehavior = filteringBehavior; + } + + [Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)] + internal static bool IsHandoffFunctionName(string name) + { + return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal); + } + + public IEnumerable FilterMessages(IEnumerable messages) + { + if (this._filteringBehavior == HandoffToolCallFilteringBehavior.None) + { + return messages; + } + + Dictionary filteringCandidates = new(); + List filteredMessages = []; + HashSet messagesToRemove = []; + + bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; + foreach (ChatMessage unfilteredMessage in messages) + { + ChatMessage filteredMessage = unfilteredMessage.Clone(); + + // .Clone() is shallow, so we cannot modify the contents of the cloned message in place. + List contents = []; + contents.Capacity = unfilteredMessage.Contents?.Count ?? 0; + filteredMessage.Contents = contents; + + // Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls + // originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result) + // FunctionCallContent. + if (unfilteredMessage.Role != ChatRole.Tool) + { + for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) + { + AIContent content = unfilteredMessage.Contents[i]; + if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name))) + { + filteredMessage.Contents.Add(content); + + // Track non-handoff function calls so their tool results are preserved in HandoffOnly mode + if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc) + { + filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId) + { + IsHandoffFunction = false, + }; + } + } + else if (filterHandoffOnly) + { + if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState)) + { + filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId) + { + IsHandoffFunction = true, + }; + } + else + { + candidateState.IsHandoffFunction = true; + (int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; + ChatMessage messageToFilter = filteredMessages[messageIndex]; + messageToFilter.Contents.RemoveAt(contentIndex); + if (messageToFilter.Contents.Count == 0) + { + messagesToRemove.Add(messageIndex); + } + } + } + else + { + // All mode: strip all FunctionCallContent + } + } + } + else + { + if (!filterHandoffOnly) + { + continue; + } + + for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) + { + AIContent content = unfilteredMessage.Contents[i]; + if (content is not FunctionResultContent frc + || (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState) + && candidateState.IsHandoffFunction is false)) + { + // Either this is not a function result content, so we should let it through, or it is a FRC that + // we know is not related to a handoff call. In either case, we should include it. + filteredMessage.Contents.Add(content); + } + else if (candidateState is null) + { + // We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later + filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) + { + FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count), + }; + } + // else we have seen the corresponding function call and it is a handoff, so we should filter it out. + } + } + + if (filteredMessage.Contents.Count > 0) + { + filteredMessages.Add(filteredMessage); + } + } + + return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index)); + } + + private class FilterCandidateState(string callId) + { + public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; } + + public string CallId => callId; + + public bool? IsHandoffFunction { get; set; } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs index 063f73bb6f..223517c35f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffStartExecutor.cs @@ -9,8 +9,23 @@ namespace Microsoft.Agents.AI.Workflows.Specialized; internal static class HandoffConstants { + internal const string HandoffOrchestrationSharedScope = "HandoffOrchestration"; + internal const string PreviousAgentTrackerKey = "LastAgentId"; - internal const string PreviousAgentTrackerScope = "HandoffOrchestration"; + internal const string PreviousAgentTrackerScope = HandoffOrchestrationSharedScope; + + internal const string MultiPartyConversationKey = "MultiPartyConversation"; + internal const string MultiPartyConversationScope = HandoffOrchestrationSharedScope; + + internal const string HandoffSharedStateKey = "SharedState"; + internal const string HandoffSharedStateScope = HandoffOrchestrationSharedScope; +} + +internal sealed class HandoffSharedState +{ + public MultiPartyConversation Conversation { get; } = new(); + + public string? PreviousAgentId { get; set; } } /// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token. @@ -29,23 +44,25 @@ internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocol protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) { - if (returnToPrevious) - { - return context.InvokeWithStateAsync( - async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) => - { - HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId); - await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false); + return context.InvokeWithStateAsync( + async (HandoffSharedState? sharedState, IWorkflowContext context, CancellationToken cancellationToken) => + { + sharedState ??= new HandoffSharedState(); + sharedState.Conversation.AddMessages(messages); - return previousAgentId; - }, - HandoffConstants.PreviousAgentTrackerKey, - HandoffConstants.PreviousAgentTrackerScope, - cancellationToken); - } + string? previousAgentId = sharedState.PreviousAgentId; - HandoffState handoff = new(new(emitEvents), null, messages); - return context.SendMessageAsync(handoff, cancellationToken); + // If we are configured to return to the previous agent, include the previous agent id in the handoff state. + // If there was no previousAgent, it will still be null. + HandoffState turnState = new(new(emitEvents), null, returnToPrevious ? previousAgentId : null); + + await context.SendMessageAsync(turnState, cancellationToken).ConfigureAwait(false); + + return sharedState; + }, + HandoffConstants.HandoffSharedStateKey, + HandoffConstants.HandoffSharedStateScope, + cancellationToken); } public new ValueTask ResetAsync() => base.ResetAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs index 644bc7df0e..24cf788cb8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs @@ -1,12 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; -using Microsoft.Extensions.AI; - namespace Microsoft.Agents.AI.Workflows.Specialized; internal sealed record class HandoffState( TurnToken TurnToken, string? RequestedHandoffTargetAgentId, - List Messages, string? PreviousAgentId = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/MultiPartyConversation.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/MultiPartyConversation.cs new file mode 100644 index 0000000000..4d59184f0d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/MultiPartyConversation.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.Specialized; + +internal sealed class MultiPartyConversation +{ + private readonly List _history = []; + private readonly object _mutex = new(); + + public List CloneAllMessages() + { + lock (this._mutex) + { + return this._history.ToList(); + } + } + + public (ChatMessage[], int) CollectNewMessages(int bookmark) + { + lock (this._mutex) + { + int count = this._history.Count - bookmark; + if (count < 0) + { + throw new InvalidOperationException($"Bookmark value too large: {bookmark} vs count={count}"); + } + + return (this._history.Skip(bookmark).ToArray(), this.CurrentBookmark); + } + } + + private int CurrentBookmark => this._history.Count; + + public int AddMessages(IEnumerable messages) + { + lock (this._mutex) + { + this._history.AddRange(messages); + return this.CurrentBookmark; + } + } + + public int AddMessage(ChatMessage message) + { + lock (this._mutex) + { + this._history.Add(message); + return this.CurrentBookmark; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs index 281d0694ac..e91531513d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostingExtensions.cs @@ -41,7 +41,7 @@ public static class WorkflowHostingExtensions { Dictionary parameters = new() { - { "data", request.Data} + { "data", request.Data } }; return new FunctionCallContent(request.RequestId, request.PortInfo.PortId, parameters); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index c1f81f0ecf..d015ecdcee 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -247,7 +247,7 @@ internal sealed class WorkflowSession : AgentSession hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal); } - AIContent normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest); + object normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest); externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId)); (matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId); } @@ -303,14 +303,35 @@ internal sealed class WorkflowSession : AgentSession /// /// Rewrites workflow-facing response content back to the original agent-owned content ID. /// - private static AIContent NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) => content switch + private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request) { - FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent) - => CloneFunctionResultContent(functionResultContent, functionCallContent.CallId), - ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent) - => CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId), - _ => content, - }; + switch (content) + { + // If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to + // on at the WorkflowSession boundary), clone it and send it in. + case FunctionResultContent functionResultContent when request.TryGetDataAs(out FunctionCallContent? functionCallContent): + return CloneFunctionResultContent(functionResultContent, functionCallContent.CallId); + case FunctionResultContent functionResultContent when !request.PortInfo.ResponseType.IsMatchPolymorphic(typeof(FunctionResultContent)): + { + object? result = functionResultContent.Result; + if (result != null) + { + if (request.PortInfo.ResponseType.IsMatchPolymorphic(result.GetType()) || result is PortableValue) + { + return result; + } + + throw new InvalidOperationException($"Unexpected result type in FunctionResultContent {result.GetType()}; expecting {request.PortInfo.ResponseType}"); + } + + throw new NotSupportedException($"Null result is not supported when using RequestPort with non-AIContent-typed requests. {functionResultContent}"); + } + case ToolApprovalResponseContent toolApprovalResponseContent when request.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent): + return CloneToolApprovalResponseContent(toolApprovalResponseContent, toolApprovalRequestContent.RequestId); + default: + return content; + } + } /// /// Gets the workflow-facing request ID from response content types. diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 236d9ae455..70f802399d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -7,6 +7,10 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.Execution; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Agents.AI.Workflows.Sample; using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; @@ -14,6 +18,27 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase { + private static async ValueTask PrepareHandoffSharedStateAsync(TestRunContext? runContext = null, IEnumerable? messages = null) + { + runContext ??= new(); + + HandoffSharedState sharedState = new(); + + if (messages != null) + { + sharedState.Conversation.AddMessages(messages); + } + + await runContext.BindWorkflowContext(nameof(HandoffStartExecutor)) + .QueueStateUpdateAsync(HandoffConstants.HandoffSharedStateKey, + sharedState, + HandoffConstants.HandoffSharedStateScope); + + await runContext.StateManager.PublishUpdatesAsync(null); + + return runContext; + } + [Theory] [InlineData(null, null)] [InlineData(null, true)] @@ -27,7 +52,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting) { // Arrange - TestRunContext testContext = new(); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); HandoffAgentExecutorOptions options = new("", @@ -39,7 +64,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase testContext.ConfigureExecutor(executor); // Act - HandoffState message = new(new(turnSetting), null, []); + HandoffState message = new(new(turnSetting), null, null); await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); // Assert @@ -55,7 +80,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting) { // Arrange - TestRunContext testContext = new(); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName); HandoffAgentExecutorOptions options = new("", @@ -67,7 +92,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase testContext.ConfigureExecutor(executor); // Act - HandoffState message = new(new(false), null, []); + HandoffState message = new(new(false), null, null); await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); // Assert @@ -75,6 +100,82 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId()); } + [Fact] + public async Task Test_HandoffAgentExecutor_ComposesWithHITLSubworkflowAsync() + { + // Arrange + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + + SendsRequestExecutor challengeSender = new(); + Workflow subworkflow = new WorkflowBuilder(challengeSender) + .AddExternalRequest(challengeSender, "SendChallengeToUser") + .WithOutputFrom(challengeSender) + .Build(); + + InProcessExecutionEnvironment environment = InProcessExecution.Lockstep.WithCheckpointing(CheckpointManager.CreateInMemory()); + AIAgent subworkflowAgent = subworkflow.AsAIAgent(includeWorkflowOutputsInResponse: true, name: "Subworkflow", executionEnvironment: environment); + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: true, + emitAgentResponseUpdateEvents: true, + HandoffToolCallFilteringBehavior.None); + + HandoffAgentExecutor executor = new(subworkflowAgent, [], options); + Workflow fakeWorkflow = new(executor.Id) { ExecutorBindings = { { executor.Id, executor } } }; + EdgeMap map = new(testContext, fakeWorkflow, null); + + testContext.ConfigureExecutor(executor, map); + + // Validate that our test assumptions hold + string functionCallPortId = $"{HandoffAgentExecutor.IdFor(subworkflowAgent)}_FunctionCall"; + map.TryGetResponsePortExecutorId(functionCallPortId, out string? responsePortExecutorId).Should().BeTrue(); + responsePortExecutorId.Should().Be(executor.Id); + + // Act + HandoffState message = new(new(false), null, null); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + await testContext.StateManager.PublishUpdatesAsync(null); + + // Assert + testContext.ExternalRequests.Should().HaveCount(1) + .And.ContainSingle(request => request.IsDataOfType()); + + FunctionCallContent functionCallContent = testContext.ExternalRequests.Single().Data.As()!; + object? requestData = functionCallContent.Arguments!["data"]; + + Challenge? challenge = null; + if (requestData is PortableValue pv) + { + challenge = pv.As(); + } + else + { + challenge = requestData as Challenge; + } + + if (challenge is null) + { + Assert.Fail($"Expected request data to be of type {typeof(Challenge).FullName}, but was {requestData?.GetType().FullName ?? "null"}"); + return; // Unreachable, but analysis cannot infer that Debug.Fail will throw/exit, and UnreachableException is not available on net472 + } + + // Act 2 + string challengeResponse = new(challenge.Value.Reverse().ToArray()); + FunctionResultContent responseContent = new(functionCallContent.CallId, new Response(challengeResponse)); + + RequestPortInfo requestPortInfo = new(new(typeof(Challenge)), new(typeof(Response)), functionCallPortId); + string requestId = $"{functionCallPortId.Length}:{functionCallPortId}:{functionCallContent.CallId}"; + DeliveryMapping? mapping = await map.PrepareDeliveryForResponseAsync(new(requestPortInfo, requestId, new(responseContent))); + + mapping!.Deliveries.Should().HaveCount(1); + + MessageDelivery delivery = mapping!.Deliveries.Single(); + + object? result = await executor.ExecuteCoreAsync(delivery.Envelope.Message, + delivery.Envelope.MessageType, + testContext.BindWorkflowContext(executor.Id)); + } + [Fact] public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndToolsAsync() { @@ -92,80 +193,113 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase HandoffTarget handoff = new(targetAgent); HandoffAgentExecutor executor = new(handoffAgent, [handoff], options); - TestWorkflowContext testContext = new(executor.Id); - HandoffState state = new(new(false), null, [], null); + TestRunContext runContext = await PrepareHandoffSharedStateAsync(); + IWorkflowContext testContext = runContext.BindWorkflowContext(executor.Id); + HandoffState state = new(new(false), null); // Act / Assert Func runStreamingAsync = async () => await executor.HandleAsync(state, testContext); await runStreamingAsync.Should().NotThrowAsync(); } +} - private sealed class OptionValidatingChatClient(string baseInstructions, string handoffInstructions, AITool baseTool) : IChatClient +internal sealed record Challenge(string Value); +internal sealed record Response(string Value); + +[SendsMessage(typeof(Challenge))] +internal sealed partial class SendsRequestExecutor(string? id = null) : ChatProtocolExecutor(id ?? nameof(SendsRequestExecutor), s_chatOptions) +{ + internal const string ChallengeString = "{C7A762AE-7DAA-4D9C-A647-E64E6DBC35AE}"; + private static string ResponseKey { get; } = new(ChallengeString.Reverse().ToArray()); + + private static readonly ChatProtocolExecutorOptions s_chatOptions = new() { - public void Dispose() + AutoSendTurnToken = false + }; + + protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) + => context.SendMessageAsync(new Challenge(ChallengeString), cancellationToken); + + [MessageHandler] + public async ValueTask HandleChallengeResponseAsync(Response response, IWorkflowContext context, CancellationToken cancellationToken = default) + { + if (response.Value != ResponseKey) { + throw new InvalidOperationException($"Incorrect response received. Expected '{ResponseKey}' but got '{response.Value}'"); } - private void CheckOptions(ChatOptions? options) - { - options.Should().NotBeNull(); + await context.SendMessageAsync(new ChatMessage(ChatRole.Assistant, "Correct response."), cancellationToken) + .ConfigureAwait(false); - options.Instructions.Should().NotBeNullOrEmpty("Handoff orchestration should preserve and augment instructions.") - .And.Contain(baseInstructions, because: "Handoff orchestration should preserve existing instructions.") - .And.Contain(handoffInstructions, because: "Handoff orchestration should inject handoff instructions."); + await context.SendMessageAsync(new TurnToken(false), cancellationToken).ConfigureAwait(false); + } +} - options.Tools.Should().NotBeNullOrEmpty("Handoff orchestration should preserve and augment tools.") - .And.Contain(tool => tool.Name == baseTool.Name, "Handoff orchestration should preserve existing tools.") - .And.Contain(tool => tool.Name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal), - because: "Handoff orchestration should inject handoff tools."); - } +internal sealed class OptionValidatingChatClient(string baseInstructions, string handoffInstructions, AITool baseTool) : IChatClient +{ + public void Dispose() + { + } - private List ResponseMessages => - [ - new ChatMessage(ChatRole.Assistant, "Ok") + private void CheckOptions(ChatOptions? options) + { + options.Should().NotBeNull(); + + options.Instructions.Should().NotBeNullOrEmpty("Handoff orchestration should preserve and augment instructions.") + .And.Contain(baseInstructions, because: "Handoff orchestration should preserve existing instructions.") + .And.Contain(handoffInstructions, because: "Handoff orchestration should inject handoff instructions."); + + options.Tools.Should().NotBeNullOrEmpty("Handoff orchestration should preserve and augment tools.") + .And.Contain(tool => tool.Name == baseTool.Name, "Handoff orchestration should preserve existing tools.") + .And.Contain(tool => tool.Name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal), + because: "Handoff orchestration should inject handoff tools."); + } + + private List ResponseMessages => + [ + new ChatMessage(ChatRole.Assistant, "Ok") { MessageId = Guid.NewGuid().ToString(), AuthorName = nameof(OptionValidatingChatClient) } - ]; + ]; - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) + { + this.CheckOptions(options); + + ChatResponse response = new(this.ResponseMessages) { - this.CheckOptions(options); + ResponseId = Guid.NewGuid().ToString("N"), + CreatedAt = DateTimeOffset.Now + }; - ChatResponse response = new(this.ResponseMessages) + return Task.FromResult(response); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceType == typeof(OptionValidatingChatClient)) + { + return this; + } + + return null; + } + + public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + this.CheckOptions(options); + + string responseId = Guid.NewGuid().ToString("N"); + foreach (ChatMessage message in this.ResponseMessages) + { + yield return new(message.Role, message.Contents) { - ResponseId = Guid.NewGuid().ToString("N"), + ResponseId = responseId, + MessageId = message.MessageId, CreatedAt = DateTimeOffset.Now }; - - return Task.FromResult(response); - } - - public object? GetService(Type serviceType, object? serviceKey = null) - { - if (serviceType == typeof(OptionValidatingChatClient)) - { - return this; - } - - return null; - } - - public async IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - this.CheckOptions(options); - - string responseId = Guid.NewGuid().ToString("N"); - foreach (ChatMessage message in this.ResponseMessages) - { - yield return new(message.Role, message.Contents) - { - ResponseId = responseId, - MessageId = message.MessageId, - CreatedAt = DateTimeOffset.Now - }; - } } } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs index 993a6d462b..dc1072aa72 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/12_HandOff_HostAsAgent.cs @@ -73,6 +73,7 @@ internal static class Step12EntryPoint foreach (string input in inputs) { AgentResponse response; + ResponseContinuationToken? continuationToken = null; do { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index 247499b72e..a290948ae4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -314,6 +314,38 @@ public class SampleSmokeTest Action CreateValidator(string expected) => actual => actual.Should().Be(expected); } + public class Step12ExpectedOutputCalculator(int agentCount) + { + private readonly int[] _bookmarks = new int[agentCount]; + private readonly List _history = new(); + private readonly HashSet _skipIndices = new(); + + public IEnumerable ExpectedOutputs => + this._history.Where((element, index) => !this._skipIndices.Contains(index)); + + public void ProcessInput(string newInput) + { + this._skipIndices.Add(this._history.Count); + this._history.Add(newInput); + + for (int i = 0; i < agentCount; i++) + { + int agentId = i + 1; + int agentBookmark = this._bookmarks[i]; + int count = this._history.Count - agentBookmark; + + count.Should().BeGreaterThanOrEqualTo(0); + + foreach (string input in this._history.Skip(agentBookmark).ToList()) + { + this._history.Add($"{agentId}:{input}"); + } + + this._bookmarks[i] = this._history.Count; + } + } + } + [Theory] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] [InlineData(ExecutionEnvironment.InProcess_OffThread)] @@ -322,14 +354,10 @@ public class SampleSmokeTest { List inputs = ["1", "2", "3"]; - using StringWriter writer = new(); - await Step12EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), inputs); - - string[] lines = writer.ToString().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); - // The expectation is that each agent will echo each input along with every echo from previous agents // E.g.: // (user): 1 + // ----- outputs below // (a1): 1:1 // (a2): 2:1 // (a2): 2:1:1 @@ -340,7 +368,35 @@ public class SampleSmokeTest // (a3): 3:2:1 // (a3): 3:2:1:1 - string[] expected = inputs.SelectMany(input => EchoesForInput(input)).ToArray(); + // If there are multiple inputs (there are), then each successive input adds to the depth of the previous + // ones, so, for example, once we do input = "1", "2": + + // (user): 1 + // (a1): 1:1 <- a1 "last seen" + // (a2): 2:1 + // (a2): 2:1:1 <- a2 "last seen" + // (user): 2 + // ----- outputs below + // (a1): 1:2:1 + // (a1): 1:2:1:1 + // (a1): 1:2 <- from user input, a1 "last seen" + // (a2): 2:2 <- from user input (note that a2 seems like it is seeing these in a different "order" than a1 - but it is not) + // (a2): 2:1:2:1 + // (a2): 2:1:2:1:1 + // (a2): 2:1:2 <- from a1's first echo, a2 "last seen" + + Step12ExpectedOutputCalculator outputGenerator = new(Step12EntryPoint.AgentCount); + foreach (string input in inputs) + { + outputGenerator.ProcessInput(input); + } + + string[] expected = outputGenerator.ExpectedOutputs.ToArray(); + + using StringWriter writer = new(); + await Step12EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment(), inputs); + + string[] lines = writer.ToString().Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); Console.Error.WriteLine("Expected lines: "); foreach (string expectedLine in expected) @@ -357,19 +413,6 @@ public class SampleSmokeTest Assert.Collection(lines, expected.Select(CreateValidator).ToArray()); - IEnumerable EchoesForInput(string input) - { - List echoes = [$"{Step12EntryPoint.EchoPrefixForAgent(1)}{input}"]; - for (int i = 2; i <= Step12EntryPoint.AgentCount; i++) - { - string agentPrefix = Step12EntryPoint.EchoPrefixForAgent(i); - List newEchoes = [$"{agentPrefix}{input}", .. echoes.Select(echo => $"{agentPrefix}{echo}")]; - echoes.AddRange(newEchoes); - } - - return echoes; - } - Action CreateValidator(string expected) => actual => actual.Should().Be(expected); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs index f94c463d59..be0d62528d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRunContext.cs @@ -27,6 +27,9 @@ public class TestRunContext : IRunnerContext internal TestRunContext ConfigureExecutor(Executor executor, EdgeMap? map = null) { + // Ensure that we have run the ProtocolBuilder + _ = executor.Protocol.Describe(); + executor.AttachRequestContext(new TestExternalRequestContext(this, executor.Id, map)); this.Executors.Add(executor.Id, executor); return this; @@ -42,6 +45,7 @@ public class TestRunContext : IRunnerContext return this; } + internal StateManager StateManager { get; } = new(); private sealed class BoundContext( string executorId, TestRunContext runnerContext, @@ -70,16 +74,16 @@ public class TestRunContext : IRunnerContext => this.AddEventAsync(new RequestHaltEvent()); public ValueTask QueueClearScopeAsync(string? scopeName = null, CancellationToken cancellationToken = default) - => default; + => runnerContext.StateManager.ClearStateAsync(executorId, scopeName); public ValueTask QueueStateUpdateAsync(string key, T? value, string? scopeName = null, CancellationToken cancellationToken = default) - => default; + => runnerContext.StateManager.WriteStateAsync(new ScopeId(executorId, scopeName), key, value); public ValueTask ReadStateAsync(string key, string? scopeName = null, CancellationToken cancellationToken = default) - => new(default(T?)); + => runnerContext.StateManager.ReadStateAsync(new ScopeId(executorId, scopeName), key); public ValueTask> ReadStateKeysAsync(string? scopeName = null, CancellationToken cancellationToken = default) - => new([]); + => runnerContext.StateManager.ReadKeysAsync(new ScopeId(executorId, scopeName)); public ValueTask SendMessageAsync(object message, string? targetId = null, CancellationToken cancellationToken = default) => runnerContext.SendMessageAsync(executorId, message, targetId, cancellationToken); From 495e1dad6bf3c62b14929805cfd5f0409c897876 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:30:54 -0700 Subject: [PATCH 05/52] Python: Fix CopilotStudioAgent to reuse conversation ID from existing session (#5299) * Fix CopilotStudioAgent to reuse existing conversation on session (#5285) CopilotStudioAgent unconditionally called _start_new_conversation() in both _run_impl and _run_stream_impl, ignoring any existing service_session_id on the session. Add a guard to only start a new conversation when there is no existing service_session_id, matching the pattern used by other agents. Also fix pre-existing pyright reportMissingImports errors for orjson in file_history_provider samples. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert out-of-scope sample file changes Remove unrelated orjson type-ignore comment changes from sample files that were outside the scope of the conversation-ID reuse fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_copilotstudio/_agent.py | 6 ++- .../copilotstudio/tests/test_copilot_agent.py | 41 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index 56a9c89081..333e75470f 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -244,7 +244,8 @@ class CopilotStudioAgent(BaseAgent): """Non-streaming implementation of run.""" if not session: session = self.create_session() - session.service_session_id = await self._start_new_conversation() + if not session.service_session_id: + session.service_session_id = await self._start_new_conversation() input_messages = normalize_messages(messages) @@ -271,7 +272,8 @@ class CopilotStudioAgent(BaseAgent): nonlocal session if not session: session = self.create_session() - session.service_session_id = await self._start_new_conversation() + if not session.service_session_id: + session.service_session_id = await self._start_new_conversation() input_messages = normalize_messages(messages) diff --git a/python/packages/copilotstudio/tests/test_copilot_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py index 77e370ab1e..49da1d7208 100644 --- a/python/packages/copilotstudio/tests/test_copilot_agent.py +++ b/python/packages/copilotstudio/tests/test_copilot_agent.py @@ -245,6 +245,47 @@ class TestCopilotStudioAgent: assert response_count == 1 assert session.service_session_id == "test-conversation-id" + async def test_run_reuses_existing_conversation( + self, mock_copilot_client: MagicMock, mock_activity: MagicMock + ) -> None: + """Test run method reuses an existing conversation ID from the session.""" + agent = CopilotStudioAgent(client=mock_copilot_client) + session = AgentSession() + session.service_session_id = "existing-conversation-id" + + mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity]) + + response = await agent.run("test message", session=session) + + assert isinstance(response, AgentResponse) + assert session.service_session_id == "existing-conversation-id" + mock_copilot_client.start_conversation.assert_not_called() + mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id") + + async def test_run_streaming_reuses_existing_conversation(self, mock_copilot_client: MagicMock) -> None: + """Test run(stream=True) method reuses an existing conversation ID from the session.""" + agent = CopilotStudioAgent(client=mock_copilot_client) + session = AgentSession() + session.service_session_id = "existing-conversation-id" + + typing_activity = MagicMock() + typing_activity.text = "Streaming response" + typing_activity.type = "typing" + typing_activity.id = "test-typing-id" + typing_activity.from_property.name = "Test Bot" + + mock_copilot_client.ask_question.return_value = create_async_generator([typing_activity]) + + response_count = 0 + async for response in agent.run("test message", session=session, stream=True): + assert isinstance(response, AgentResponseUpdate) + response_count += 1 + + assert response_count == 1 + assert session.service_session_id == "existing-conversation-id" + mock_copilot_client.start_conversation.assert_not_called() + mock_copilot_client.ask_question.assert_called_once_with("test message", "existing-conversation-id") + async def test_run_streaming_no_typing_activity(self, mock_copilot_client: MagicMock) -> None: """Test run(stream=True) method with non-typing activity.""" agent = CopilotStudioAgent(client=mock_copilot_client) From 69894eded89d6e8ebf7bdb75cd0d9da54ade8b21 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 20 Apr 2026 10:29:40 +0200 Subject: [PATCH 06/52] Python: Flatten hyperlight execute_code output (#5333) * small fix for hyperlight * improved sandbox dependency --- .../_execute_code_tool.py | 11 +- .../_instructions.py | 13 + python/packages/hyperlight/pyproject.toml | 2 +- .../hyperlight/samples/codeact_benchmark.py | 253 ++++++++++++++++++ .../samples/codeact_context_provider.py | 14 +- .../hyperlight/test_hyperlight_codeact.py | 96 ++----- python/uv.lock | 10 +- 7 files changed, 309 insertions(+), 90 deletions(-) create mode 100644 python/packages/hyperlight/samples/codeact_benchmark.py diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index b15a2569c1..a46707ac0d 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -431,7 +431,7 @@ def _build_execution_contents( outputs.append(Content.from_text(stderr, raw_representation=result)) if not outputs: outputs.append(Content.from_text("Code executed successfully without output.")) - return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + return outputs error_details = stderr or "Unknown sandbox error" outputs.append( @@ -441,12 +441,16 @@ def _build_execution_contents( raw_representation=result, ) ) - return [Content.from_code_interpreter_tool_result(outputs=outputs, raw_representation=result)] + return outputs def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]: sandbox_tool = copy.copy(tool_obj) - sandbox_tool.result_parser = _passthrough_result_parser + # Auto-assign a passthrough parser so the raw return value round-trips through + # `ast.literal_eval` in the sandbox callback below. User-supplied parsers are + # left in place so callers can customize how results are exposed to the guest. + if sandbox_tool.result_parser is None: + sandbox_tool.result_parser = _passthrough_result_parser def _callback(**kwargs: Any) -> Any: async def _invoke() -> list[Content]: @@ -765,6 +769,7 @@ class HyperlightExecuteCodeTool(FunctionTool): return build_codeact_instructions( tools=config.tools, tools_visible_to_model=tools_visible_to_model, + filesystem_enabled=config.filesystem_enabled, ) def create_run_tool(self) -> HyperlightExecuteCodeTool: diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py index f866c1349c..c44a183062 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_instructions.py @@ -68,6 +68,7 @@ def build_codeact_instructions( *, tools: Sequence[FunctionTool], tools_visible_to_model: bool, + filesystem_enabled: bool = False, ) -> str: """Build dynamic CodeAct instructions for the effective sandbox state.""" usage_note = ( @@ -77,12 +78,24 @@ def build_codeact_instructions( else "Provider-owned sandbox tools are not exposed separately; use `execute_code` when you need them." ) + output_note = ( + "To surface results from `execute_code`, end the code with `print(...)`; the sandbox does not " + "return the value of the last expression." + ) + if filesystem_enabled: + output_note += ( + " For larger artifacts, write them to `/output/` instead — returned files will be " + "attached to the tool result." + ) + return f"""You have one primary tool: execute_code. Prefer one execute_code call per request when possible. Its tool description contains the current `call_tool(...)` guidance, sandbox tool registry, and capability limits. +{output_note} + {usage_note} """ diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 9884152043..21034b1a8e 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "agent-framework-core>=1.0.0,<2", "hyperlight-sandbox>=0.3.0,<0.4", - "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; (sys_platform == 'linux' or sys_platform == 'win32') and python_version < '3.14'", + "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((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.3.0,<0.4", ] diff --git a/python/packages/hyperlight/samples/codeact_benchmark.py b/python/packages/hyperlight/samples/codeact_benchmark.py new file mode 100644 index 0000000000..275187d3b8 --- /dev/null +++ b/python/packages/hyperlight/samples/codeact_benchmark.py @@ -0,0 +1,253 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Benchmark CodeAct vs. traditional tool-calling for a multi-tool-call task. + +This sample runs the same prompt against the same FoundryChatClient twice: + +1. **Traditional tool-calling**: the five business tools are passed directly to + the agent, so the model calls each tool individually via the LLM tool-call + interface. +2. **CodeAct**: the same tools are registered on a HyperlightCodeActProvider + and the model sees a single ``execute_code`` tool that calls them from + inside the Hyperlight sandbox via ``call_tool(...)``. + +The task (computing grand totals per user) naturally requires many tool calls +to complete. At the end, the sample prints elapsed time and token usage for +each run so the two approaches can be compared. + +Run with: + cd python + uv run --directory packages/hyperlight python samples/codeact_benchmark.py + +Required environment variables (loaded from ``.env`` if present): + FOUNDRY_PROJECT_ENDPOINT + FOUNDRY_MODEL +""" + +from __future__ import annotations + +import asyncio +import os +import time +from typing import Annotated, Any, Literal + +from agent_framework import Agent, AgentResponse, UsageDetails +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel, Field + +from agent_framework_hyperlight import HyperlightCodeActProvider + +load_dotenv() + + +# 1. Deterministic "business" data and tools. + +_USERS: list[dict[str, Any]] = [ + {"id": 1, "name": "Alice", "region": "EU", "tier": "gold"}, + {"id": 2, "name": "Bob", "region": "US", "tier": "silver"}, + {"id": 3, "name": "Charlie", "region": "US", "tier": "gold"}, + {"id": 4, "name": "Diana", "region": "APAC", "tier": "bronze"}, + {"id": 5, "name": "Evan", "region": "EU", "tier": "silver"}, + {"id": 6, "name": "Fiona", "region": "US", "tier": "gold"}, + {"id": 7, "name": "George", "region": "APAC", "tier": "gold"}, + {"id": 8, "name": "Hana", "region": "EU", "tier": "bronze"}, +] + +_ORDERS: dict[int, list[dict[str, Any]]] = { + 1: [{"product": "Widget", "qty": 3, "unit_price": 9.99}, {"product": "Gadget", "qty": 1, "unit_price": 19.99}], + 2: [{"product": "Widget", "qty": 1, "unit_price": 9.99}], + 3: [{"product": "Gadget", "qty": 2, "unit_price": 19.99}, {"product": "Thingamajig", "qty": 4, "unit_price": 4.50}], + 4: [{"product": "Widget", "qty": 10, "unit_price": 9.99}], + 5: [{"product": "Gadget", "qty": 1, "unit_price": 19.99}], + 6: [{"product": "Widget", "qty": 2, "unit_price": 9.99}, {"product": "Thingamajig", "qty": 5, "unit_price": 4.50}], + 7: [{"product": "Gadget", "qty": 3, "unit_price": 19.99}], + 8: [{"product": "Thingamajig", "qty": 2, "unit_price": 4.50}], +} + +_DISCOUNTS: dict[str, float] = {"gold": 0.20, "silver": 0.10, "bronze": 0.05} +_TAX_RATES: dict[str, float] = {"EU": 0.21, "US": 0.08, "APAC": 0.10} + + +def list_users() -> list[dict[str, Any]]: + """Return all users as a list of dictionaries. + + Each entry has keys: id (int), name (str), region (str), tier (str). + """ + return _USERS + + +def get_orders_for_user( + user_id: Annotated[int, "The user id whose orders to retrieve."], +) -> list[dict[str, Any]]: + """Return the user's orders as a list of dictionaries. + + Each entry has keys: product (str), qty (int), unit_price (float). + """ + return _ORDERS.get(user_id, []) + + +def get_discount_rate( + tier: Annotated[Literal["gold", "silver", "bronze"], "The customer tier."], +) -> float: + """Return the discount rate as a float fraction (e.g. 0.2 for 20%).""" + return _DISCOUNTS[tier] + + +def get_tax_rate( + region: Annotated[Literal["EU", "US", "APAC"], "The region code."], +) -> float: + """Return the tax rate as a float fraction (e.g. 0.21 for 21%).""" + return _TAX_RATES[region] + + +def compute_line_total( + qty: Annotated[int, "Line item quantity."], + unit_price: Annotated[float, "Line item unit price."], + discount_rate: Annotated[float, "Discount rate as a fraction (e.g. 0.2 for 20%)."], + tax_rate: Annotated[float, "Tax rate as a fraction (e.g. 0.21 for 21%)."], +) -> float: + """Compute a single order line total. + + Formula: qty * unit_price * (1 - discount_rate) * (1 + tax_rate), rounded to 2 decimals. + """ + subtotal = qty * unit_price + discounted = subtotal * (1.0 - discount_rate) + return round(discounted * (1.0 + tax_rate), 2) + + +TOOLS = [list_users, get_orders_for_user, get_discount_rate, get_tax_rate, compute_line_total] + + +# 2. Structured output schema shared between both runs. + + +class UserTotal(BaseModel): + """A user's grand total of all their orders.""" + + user_id: int = Field(description="The user's id.") + name: str = Field(description="The user's display name.") + grand_total: float = Field(description="Sum of all line totals, rounded to 2 decimals.") + + +class UserGrandTotals(BaseModel): + """Structured output schema for both runs.""" + + results: list[UserTotal] = Field(description="One entry per user, sorted by grand_total descending.") + + +INSTRUCTIONS = "You are a careful assistant. Use the provided tools for every lookup and computation." + +BENCHMARK_PROMPT = ( + "For every user in our system (there are 8 of them), compute the grand total of all their orders. " + "Use the compute_line_total tool for each user's orders, after looking up the relevant discount and " + "tax rates for that user. " + "Use the provided tools for EVERY data lookup (users, orders, discount rates, tax rates) and for EVERY " + "line-total computation via compute_line_total — do not invent values or hardcode any numbers. " + "The total per order item should apply the discount first and then the tax " + "(e.g. total = qty * unit_price * (1-discount) * (1+tax)). " + "Return one entry per user, sorted by grand_total descending." +) + + +def get_client() -> FoundryChatClient: + """Create a FoundryChatClient from environment variables.""" + return FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + +# 3. Two runners that share the same tools, prompt, and structured output schema. + + +async def _run_traditional() -> tuple[float, AgentResponse]: + agent = Agent( + client=get_client(), + name="TraditionalAgent", + instructions=INSTRUCTIONS, + tools=TOOLS, + default_options={"response_format": UserGrandTotals}, + ) + start = time.perf_counter() + result = await agent.run(BENCHMARK_PROMPT) + elapsed = time.perf_counter() - start + return elapsed, result + + +async def _run_codeact() -> tuple[float, AgentResponse]: + codeact = HyperlightCodeActProvider( + tools=TOOLS, + approval_mode="never_require", + ) + agent = Agent( + client=get_client(), + name="CodeActAgent", + instructions=INSTRUCTIONS, + context_providers=[codeact], + default_options={"response_format": UserGrandTotals}, + ) + start = time.perf_counter() + result = await agent.run(BENCHMARK_PROMPT) + elapsed = time.perf_counter() - start + return elapsed, result + + +# 4. Report results side by side. + + +def _print_section(title: str) -> None: + bar = "=" * 70 + print(f"\n{bar}\n{title}\n{bar}") + + +def _format_usage(usage: UsageDetails | None) -> str: + if usage is None: + return "usage=" + return ( + f"input={usage.get('input_token_count') or 0:>6} " + f"output={usage.get('output_token_count') or 0:>6} " + f"total={usage.get('total_token_count') or 0:>6}" + ) + + +def _print_results(result: AgentResponse) -> None: + if result.value is not None: + for row in result.value.results: + print(f" user_id={row.user_id:>2} name={row.name:<8} grand_total={row.grand_total:>8.2f}") + else: + print(result.text) + + +async def main() -> None: + """Run the benchmark and print a comparison.""" + trad_time, trad_result = await _run_traditional() + code_time, code_result = await _run_codeact() + + _print_section("Traditional tool-calling") + print(f"time={trad_time:7.2f}s {_format_usage(trad_result.usage_details)}") + _print_results(trad_result) + + _print_section("CodeAct (HyperlightCodeActProvider)") + print(f"time={code_time:7.2f}s {_format_usage(code_result.usage_details)}") + _print_results(code_result) + + _print_section("Comparison") + trad_total = (trad_result.usage_details or {}).get("total_token_count") or 0 + code_total = (code_result.usage_details or {}).get("total_token_count") or 0 + + def pct(new: float, old: float) -> str: + if old == 0: + return "n/a" + delta = (new - old) / old * 100 + sign = "+" if delta >= 0 else "" + return f"{sign}{delta:.1f}%" + + print(f"time : traditional={trad_time:7.2f}s codeact={code_time:7.2f}s delta={pct(code_time, trad_time)}") + print(f"tokens : traditional={trad_total:7d} codeact={code_total:7d} delta={pct(code_total, trad_total)}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/packages/hyperlight/samples/codeact_context_provider.py b/python/packages/hyperlight/samples/codeact_context_provider.py index c0cc03c2f6..81b55034e5 100644 --- a/python/packages/hyperlight/samples/codeact_context_provider.py +++ b/python/packages/hyperlight/samples/codeact_context_provider.py @@ -72,15 +72,11 @@ async def log_function_calls( result = context.result if function_name == "execute_code" and isinstance(result, list): - for item in result: - if item.type != "code_interpreter_tool_result": - continue - - for output in item.outputs or []: - if output.type == "text" and output.text: - print(f"{_GREEN}stdout:\n{output.text}{_RESET}") - if output.type == "error" and output.error_details: - print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") + for output in result: + if output.type == "text" and output.text: + print(f"{_GREEN}stdout:\n{output.text}{_RESET}") + elif output.type == "error" and output.error_details: + print(f"{_YELLOW}stderr:\n{output.error_details}{_RESET}") else: print(f"{_YELLOW}◀ {function_name} → {result!r}{_RESET}") diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index 528b6e3b5b..ab6a3f7c78 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -289,38 +289,20 @@ class _FakeSessionContext: self.tools.append((source_id, tools)) -def _extract_execute_code_result(function_result: Content) -> Content: +def _extract_text_output(function_result: Content) -> str: assert function_result.type == "function_result" assert function_result.exception is None, ( f"execute_code raised {function_result.exception!r} with items={function_result.items!r}" ) - - code_result = next( - (item for item in function_result.items or [] if item.type == "code_interpreter_tool_result"), + text_output = next( + (item for item in function_result.items or [] if item.type == "text" and item.text is not None), None, ) - if code_result is not None: - return code_result - - text_outputs = [item for item in function_result.items or [] if item.type == "text"] - if text_outputs: - return Content.from_code_interpreter_tool_result(outputs=text_outputs) - + if text_output is not None and text_output.text is not None: + return text_output.text if function_result.result: - return Content.from_code_interpreter_tool_result(outputs=[Content.from_text(function_result.result)]) - - raise AssertionError(f"execute_code returned no usable outputs: {function_result.items!r}") - - -def _extract_text_output(result_content: Content) -> str: - code_result = _extract_execute_code_result(result_content) - text_output = next( - (item for item in code_result.outputs or [] if item.type == "text" and item.text is not None), None - ) - assert text_output is not None and text_output.text is not None, ( - f"Expected text output from execute_code, got {code_result.outputs!r}" - ) - return text_output.text + return function_result.result + raise AssertionError(f"Expected text output from execute_code, got {function_result.items!r}") class _FakeCodeActChatClient(FunctionInvocationLayer[Any], BaseChatClient[Any]): @@ -432,7 +414,7 @@ async def test_execute_code_tool_populates_input_dir_with_workspace_and_file_mou ) result = await execute_code.invoke(arguments={"code": "None"}) - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" assert _FakeSandbox.instances[0].input_dir is not None input_root = Path(_FakeSandbox.instances[0].input_dir) @@ -493,11 +475,9 @@ async def test_execute_code_tool_executes_with_structured_content(monkeypatch: p result = await execute_code.invoke(arguments={"code": "create-output"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert result[0].outputs[0].type == "text" - assert result[0].outputs[0].text == "done\n" - assert any(item.type == "data" for item in result[0].outputs) + assert result[0].type == "text" + assert result[0].text == "done\n" + assert any(item.type == "data" for item in result) assert _FakeSandbox.instances[0].allowed_domains == [("api.example.com", ["GET"])] assert "compute" in _FakeSandbox.instances[0].registered_tools @@ -512,11 +492,8 @@ async def test_execute_code_tool_collects_output_files_without_backend_listing( ) result = await execute_code.invoke(arguments={"code": "create-output"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert any( - item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs - ) + assert result[0].type == "text" + assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result) async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( @@ -535,11 +512,7 @@ async def test_execute_code_tool_waits_for_unlisted_output_files_to_appear( for writer_thread in _FakeSandboxWithDelayedUnlistedOutput.writer_threads: writer_thread.join() - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert any( - item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result[0].outputs - ) + assert any(item.type == "data" and item.additional_properties["path"] == "/output/report.txt" for item in result) async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pytest.MonkeyPatch) -> None: @@ -549,10 +522,8 @@ async def test_execute_code_tool_failure_returns_error_content(monkeypatch: pyte execute_code = HyperlightExecuteCodeTool() result = await execute_code.invoke(arguments={"code": "fail"}) - assert result[0].type == "code_interpreter_tool_result" - assert result[0].outputs is not None - assert result[0].outputs[0].type == "error" - assert result[0].outputs[0].error_details == "sandbox boom" + assert result[0].type == "error" + assert result[0].error_details == "sandbox boom" async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_rejects_host_targets( @@ -596,7 +567,7 @@ async def test_execute_code_tool_retries_allowed_domains_with_urls_when_backend_ execute_code = HyperlightExecuteCodeTool(allowed_domains=[("127.0.0.1:8080", "get")]) result = await execute_code.invoke(arguments={"code": "None"}) - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" assert len(_FakeStrictNetworkSandbox.instances) == 2 assert _FakeStrictNetworkSandbox.instances[0].allowed_domains == [("127.0.0.1:8080", ["GET"])] assert _FakeStrictNetworkSandbox.instances[1].allowed_domains == [ @@ -731,8 +702,7 @@ async def test_provider_run_tool_writes_files_with_real_sandbox(tmp_path: Path) } ) - assert result[0].type == "code_interpreter_tool_result" - outputs = result[0].outputs or [] + outputs = result error_outputs = [ f"{item.message}: {item.error_details}" for item in outputs @@ -795,8 +765,7 @@ async def test_provider_run_tool_pings_bing_with_real_sandbox() -> None: } ) - assert result[0].type == "code_interpreter_tool_result" - outputs = result[0].outputs or [] + outputs = result error_outputs = [ f"{item.message}: {item.error_details}" for item in outputs @@ -823,9 +792,7 @@ async def test_sandbox_runs_simple_code(restored_sandbox) -> None: @skip_if_hyperlight_integration_tests_disabled async def test_sandbox_stdout_and_stderr_captured(restored_sandbox) -> None: - result = restored_sandbox.run( - 'import sys\nprint("out")\nprint("err", file=sys.stderr)' - ) + result = restored_sandbox.run('import sys\nprint("out")\nprint("err", file=sys.stderr)') assert result.success assert "out" in result.stdout assert "err" in result.stderr @@ -910,24 +877,17 @@ async def test_output_dir_cleared_between_invocations() -> None: # First invocation: write a file result1 = await run_tool.invoke( - arguments={ - "code": ( - 'with open("/output/stale.txt", "w") as f:\n' - ' f.write("first")\n' - 'print("wrote")\n' - ) - } + arguments={"code": ('with open("/output/stale.txt", "w") as f:\n f.write("first")\nprint("wrote")\n')} ) - assert result1[0].type == "code_interpreter_tool_result" - outputs1 = result1[0].outputs or [] + assert result1[0].type == "text" or result1[0].type == "data" + outputs1 = result1 assert any( - item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") - for item in outputs1 + item.type == "data" and "stale.txt" in (item.additional_properties or {}).get("path", "") for item in outputs1 ), "First invocation should produce stale.txt" # Second invocation: no file writes result2 = await run_tool.invoke(arguments={"code": 'print("clean")\n'}) - outputs2 = result2[0].outputs or [] + outputs2 = result2 stale_files = [ item for item in outputs2 @@ -971,11 +931,9 @@ async def test_run_code_does_not_block_event_loop() -> None: concurrent_ran = True release.set() - code_task = asyncio.create_task( - run_tool.invoke(arguments={"code": 'print("done")\n'}) - ) + code_task = asyncio.create_task(run_tool.invoke(arguments={"code": 'print("done")\n'})) await _concurrent_task() result = await code_task assert concurrent_ran, "Event loop was blocked during sandbox execution" - assert result[0].type == "code_interpreter_tool_result" + assert result[0].type == "text" diff --git a/python/uv.lock b/python/uv.lock index a0090d1f75..370fd7e46d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -553,7 +553,7 @@ source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "hyperlight-sandbox", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')" }, { name = "hyperlight-sandbox-python-guest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] @@ -561,7 +561,7 @@ dependencies = [ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "hyperlight-sandbox", specifier = ">=0.3.0,<0.4" }, - { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, + { name = "hyperlight-sandbox-backend-wasm", marker = "(python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux') or (python_full_version < '3.14' and platform_machine == 'AMD64' and sys_platform == 'win32')", specifier = ">=0.3.0,<0.4" }, { name = "hyperlight-sandbox-python-guest", specifier = ">=0.3.0,<0.4" }, ] @@ -2426,7 +2426,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" }, { url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" }, { url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" }, - { url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" }, { url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" }, { url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" }, { url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" }, @@ -2434,7 +2433,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" }, { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" }, { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" }, { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" }, { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" }, { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" }, @@ -2443,7 +2441,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -2452,7 +2449,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -2461,7 +2457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -2470,7 +2465,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, From 60af59ba8b3c871065d0a288f21bfd7f0d6be3c1 Mon Sep 17 00:00:00 2001 From: Tommaso Stocchi Date: Mon, 20 Apr 2026 13:12:54 +0200 Subject: [PATCH 07/52] .NET: Features/3768-devui-aspire-integration (#3771) * adds devui integration and samples * adds unit tests for devui integration * fix: correct formatting of copyright notice in unit test files * fixes formatting issues * fixes build for net8 target * fixes formatting errors on test apphost * adds copyright notice to multiple files and removes unnecessary using directives * Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/DevUIIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/aspire-integration/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Refactor project files to use TargetFrameworks instead of TargetFramework for multi-targeting support; add optional port property to DevUIResource class. * Add unit tests for DevUIAggregatorHostedService; refactor project files for TargetFrameworks support * Refactor project files to use TargetFrameworks for multi-targeting support in DevUIIntegration samples * Remove unnecessary using directive for Aspire.Hosting in DevUIAggregatorHostedServiceTests * merge * fixes Conversation routing for non-first backends * add documentation for devui integration sample * update project references in solution file for improved integration * fixes package versions post merge * move Aspire.Hosting.AgentFramework.DevUI to dotnet/src Move the project from aspire-integration/ to src/ to be consistent with the location of all other projects in the repo. * move DevUI sample to samples/05-end-to-end/DevUIAspireIntegration Move the sample from samples/DevUIIntegration/ to samples/05-end-to-end/DevUIAspireIntegration/ to match the location of other end-to-end samples. * remove unnecessary net472 framework condition from sample csproj files These projects only target net10.0, so the Condition="'$(TargetFramework)' != 'net472'" on ItemGroup is unnecessary. * update sample model name from gpt-4.1 to gpt-5.4 Use a more up-to-date model name in the DevUI integration samples. * Revert "remove unnecessary net472 framework condition from sample csproj files" This reverts commit 08cf41253bebdf0f2bbf4c567f6b686aa6c603be. * fix: use TargetFrameworks to override multi-targeting from Directory.Build.props The parent Directory.Build.props sets TargetFrameworks to net10.0;net472, which overrides the singular TargetFramework in each csproj. Use the plural TargetFrameworks property set to net10.0 only to properly override it, and remove the now-unnecessary net472 condition on ItemGroup. * fixes aspire config * fix: update Microsoft.Extensions packages to version 10.0.1 * Address Copilot review feedback on DevUI Aspire integration - Fix request body dropping in ProxyConversationsAsync: always read the body when ContentLength > 0 before routing, then pass it through to all proxy calls (previously null was passed when backend was resolved from query param or conversation map) - Fix resource leak: dispose aggregator on startup failure in catch block - Fix XML docs: accurately describe embedded resource serving behavior - Remove reflection from DevUIResourceTests (InternalsVisibleTo already set) - Make sensitive telemetry conditional on Development environment in samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: update chat client version to gpt41 in both EditorAgent and WriterAgent --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + dotnet/Directory.Packages.props | 35 +- dotnet/agent-framework-dotnet.slnx | 10 + dotnet/agent-framework-release.slnf | 3 +- .../.aspire/settings.json | 3 + .../DevUIAspireIntegration/.gitignore | 1 + .../DevUIIntegration.AppHost.csproj | 29 + .../DevUIIntegration.AppHost/Program.cs | 32 + .../Properties/launchSettings.json | 34 + .../DevUIIntegration.AppHost/appsettings.json | 14 + .../DevUIIntegration.ServiceDefaults.csproj | 22 + .../Extensions.cs | 130 +++ .../EditorAgent/EditorAgent.csproj | 20 + .../EditorAgent/Program.cs | 51 ++ .../Properties/launchSettings.json | 14 + .../DevUIAspireIntegration/README.md | 99 +++ .../WriterAgent/Program.cs | 32 + .../Properties/launchSettings.json | 14 + .../WriterAgent/WriterAgent.csproj | 20 + .../DevUIAspireIntegration/aspire.config.json | 5 + .../AgentEntityInfo.cs | 37 + .../AgentFrameworkBuilderExtensions.cs | 185 +++++ .../AgentServiceAnnotation.cs | 64 ++ ...Aspire.Hosting.AgentFramework.DevUI.csproj | 25 + .../DevUIAggregatorHostedService.cs | 779 ++++++++++++++++++ .../DevUIResource.cs | 49 ++ .../README.md | 104 +++ .../AgentEntityInfoTests.cs | 184 +++++ .../AgentFrameworkBuilderExtensionsTests.cs | 567 +++++++++++++ .../AgentServiceAnnotationTests.cs | 167 ++++ ...ting.AgentFramework.DevUI.UnitTests.csproj | 19 + .../DevUIAggregatorHostedServiceTests.cs | 298 +++++++ .../DevUIResourceTests.cs | 195 +++++ 33 files changed, 3225 insertions(+), 17 deletions(-) create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/.aspire/settings.json create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/.gitignore create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Program.cs create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/appsettings.json create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Properties/launchSettings.json create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/README.md create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Properties/launchSettings.json create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj create mode 100644 dotnet/samples/05-end-to-end/DevUIAspireIntegration/aspire.config.json create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs create mode 100644 dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs create mode 100644 dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs diff --git a/.gitignore b/.gitignore index 4994e9e2fe..2267fb20c4 100644 --- a/.gitignore +++ b/.gitignore @@ -235,3 +235,4 @@ python/dotnet-ref # Generated filtered solution files (created by eng/scripts/New-FilteredSolution.ps1) dotnet/filtered-*.slnx +**/*.lscache diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 6817ac3fe0..63800e8a33 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -7,13 +7,16 @@ - 13.0.2 + 13.1.0 + + + @@ -48,12 +51,12 @@ - - - - - - + + + + + + @@ -71,18 +74,18 @@ - - - - - - + + + + + + - + - + - + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 69c5698a88..96c2411e3b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -4,6 +4,9 @@ + + + @@ -37,6 +40,12 @@ + + + + + + @@ -542,6 +551,7 @@ + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 98f9dcf887..8cc97dc8cb 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -28,7 +28,8 @@ "src\\Microsoft.Agents.AI.Workflows.Declarative\\Microsoft.Agents.AI.Workflows.Declarative.csproj", "src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj", "src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj", - "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj" + "src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj", + "src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj" ] } } diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.aspire/settings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.aspire/settings.json new file mode 100644 index 0000000000..842d8f7ce6 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" +} \ No newline at end of file diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.gitignore b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.gitignore new file mode 100644 index 0000000000..bdc7d02918 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/.gitignore @@ -0,0 +1 @@ +**/**/*.Development.json diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj new file mode 100644 index 0000000000..35c8eb709d --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj @@ -0,0 +1,29 @@ + + + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Program.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Program.cs new file mode 100644 index 0000000000..562e61521b --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Program.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +var builder = DistributedApplication.CreateBuilder(args); + +var foundry = builder.AddAzureAIFoundry("foundry"); + +// Comment the following lines to create a new Foundry instance instead of connecting to an existing one. If creating a new instance, the DevUI resource will wait for the Foundry to be ready before starting, ensuring the DevUI frontend is available as soon as the app starts. +var existingFoundryName = builder.AddParameter("existingFoundryName") + .WithDescription("The name of the existing Azure Foundry resource."); +var existingFoundryResourceGroup = builder.AddParameter("existingFoundryResourceGroup") + .WithDescription("The resource group of the existing Azure Foundry resource."); +foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup); + +// Add the writer agent service +var writerAgent = builder.AddProject("writer-agent") + .WithHttpHealthCheck("/health") + .WithReference(foundry).WaitFor(foundry); + +// Add the editor agent service +var editorAgent = builder.AddProject("editor-agent") + .WithHttpHealthCheck("/health") + .WithReference(foundry).WaitFor(foundry); + +// Add DevUI integration that aggregates agents from all agent services. +// Agent metadata is declared here so backends don't need a /v1/entities endpoint. +_ = builder.AddDevUI("devui") + .WithAgentService(writerAgent, agents: [new("writer")]) // the name of the agent should match the agent declaration in WriterAgent/Program.cs + .WithAgentService(editorAgent, agents: [new("editor")]) // the name of the agent should match the agent declaration in EditorAgent/Program.cs + .WaitFor(writerAgent) + .WaitFor(editorAgent); + +builder.Build().Run(); diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..1012f97aa1 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/Properties/launchSettings.json @@ -0,0 +1,34 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:16500;http://localhost:16501", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:17250", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:18100", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:17250", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true" + } + }, + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:16501", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:17251", + "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18101", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:17251", + "ASPIRE_SHOW_DASHBOARD_RESOURCES": "true", + "ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true" + } + } + } +} diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/appsettings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/appsettings.json new file mode 100644 index 0000000000..bfe8cb0cde --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.AppHost/appsettings.json @@ -0,0 +1,14 @@ +{ + "Azure": { + "TenantId": "", + "SubscriptionId": "", + "AllowResourceGroupCreation": true, + "ResourceGroup": "", + "Location": "", + "CredentialSource": "AzureCli" + }, + "Parameters": { + "existingFoundryName": "", + "existingFoundryResourceGroup": "" + } +} diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj new file mode 100644 index 0000000000..0c5573beac --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/DevUIIntegration.ServiceDefaults.csproj @@ -0,0 +1,22 @@ + + + + net10.0 + enable + enable + true + + + + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs new file mode 100644 index 0000000000..504bc71621 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/DevUIIntegration.ServiceDefaults/Extensions.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Logging; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Trace; + +namespace Microsoft.Extensions.Hosting; + +// Adds common Aspire services: service discovery, resilience, health checks, and OpenTelemetry. +// This project should be referenced by each service project in your solution. +// To learn more about using this project, see https://aka.ms/dotnet/aspire/service-defaults +#pragma warning disable CA1724 // Type name 'Extensions' conflicts with namespace - acceptable for Aspire pattern +public static class Extensions +#pragma warning restore CA1724 +{ + private const string HealthEndpointPath = "/health"; + private const string AlivenessEndpointPath = "/alive"; + + public static TBuilder AddServiceDefaults(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.ConfigureOpenTelemetry(); + + builder.AddDefaultHealthChecks(); + + builder.Services.AddServiceDiscovery(); + + builder.Services.ConfigureHttpClientDefaults(http => + { + // Turn on resilience by default + http.AddStandardResilienceHandler(); + + // Turn on service discovery by default + http.AddServiceDiscovery(); + }); + + // Uncomment the following to restrict the allowed schemes for service discovery. + // builder.Services.Configure(options => + // { + // options.AllowedSchemes = ["https"]; + // }); + + return builder; + } + + public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Logging.AddOpenTelemetry(logging => + { + logging.IncludeFormattedMessage = true; + logging.IncludeScopes = true; + }); + + builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => + { + metrics.AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddRuntimeInstrumentation(); + }) + .WithTracing(tracing => + { + tracing.AddSource(builder.Environment.ApplicationName) + .AddAspNetCoreInstrumentation(tracing => + // Exclude health check requests from tracing + tracing.Filter = context => + !context.Request.Path.StartsWithSegments(HealthEndpointPath) + && !context.Request.Path.StartsWithSegments(AlivenessEndpointPath) + ) + // Uncomment the following line to enable gRPC instrumentation (requires the OpenTelemetry.Instrumentation.GrpcNetClient package) + //.AddGrpcClientInstrumentation() + .AddHttpClientInstrumentation(); + }); + + builder.AddOpenTelemetryExporters(); + + return builder; + } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } + + public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + builder.Services.AddHealthChecks() + // Add a default liveness check to ensure app is responsive + .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); + + return builder; + } + + public static WebApplication MapDefaultEndpoints(this WebApplication app) + { + // Adding health checks endpoints to applications in non-development environments has security implications. + // See https://aka.ms/dotnet/aspire/healthchecks for details before enabling these endpoints in non-development environments. + if (app.Environment.IsDevelopment()) + { + // All health checks must pass for app to be considered ready to accept traffic after starting + app.MapHealthChecks(HealthEndpointPath); + + // Only health checks tagged with the "live" tag must pass for app to be considered alive + app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions + { + Predicate = r => r.Tags.Contains("live") + }); + } + + return app; + } +} diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj new file mode 100644 index 0000000000..865af164b0 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/EditorAgent.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + b2c3d4e5-f6a7-8901-bcde-f12345678901 + + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs new file mode 100644 index 0000000000..d50213a9f7 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Program.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ComponentModel; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Extensions.AI; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureChatCompletionsClient(connectionName: "foundry", + configureSettings: settings => + { + settings.TokenCredential = new DefaultAzureCredential(); + settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment(); + }) + .AddChatClient("gpt41"); + +builder.AddAIAgent("editor", (sp, key) => +{ + var chatClient = sp.GetRequiredService(); + return new ChatClientAgent( + chatClient, + name: key, + instructions: "You edit short stories to improve grammar and style, ensuring the stories are less than 300 words. Once finished editing, you select a title and format the story for publishing.", + tools: [AIFunctionFactory.Create(FormatStory)] + ); +}); + +// Register services for OpenAI responses and conversations +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +// Map OpenAI API endpoints — DevUI aggregator routes requests here +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +app.MapDefaultEndpoints(); + +app.Run(); + +[Description("Formats the story for publication, revealing its title.")] +static string FormatStory(string title, string story) => $""" + **Title**: {title} + + {story} + """; diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..3ad5a6f098 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/EditorAgent/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5281", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/README.md b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/README.md new file mode 100644 index 0000000000..22f135eaa3 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/README.md @@ -0,0 +1,99 @@ +# DevUI Integration Sample + +This sample demonstrates how to use the **Aspire.Hosting.AgentFramework.DevUI** library to test and debug multiple AI agents through a unified DevUI web interface, orchestrated by an Aspire AppHost. + +The solution contains two agent services: + +- **WriterAgent** — a simple agent that writes short stories (≤ 300 words) about a given topic. +- **EditorAgent** — an agent that edits stories for grammar and style, selects a title, and formats the result for publishing. It also demonstrates tool use via `AIFunctionFactory`. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) +- [Aspire CLI](https://learn.microsoft.com/dotnet/aspire/fundamentals/setup-tooling) +- An Azure subscription with access to [Azure AI Foundry](https://learn.microsoft.com/azure/ai-studio/) +- Azure CLI authenticated (`az login`) + +## Azure AI Foundry configuration + +The sample requires an Azure AI Foundry resource with a deployed `gpt-4.1` model. You have two options: + +### Option 1: Connect to an existing Foundry resource + +Fill in the parameters in `DevUIIntegration.AppHost/appsettings.json`: + +```json +{ + "Azure": { + "TenantId": "", + "SubscriptionId": "", + "AllowResourceGroupCreation": true, + "ResourceGroup": "", + "Location": "", + "CredentialSource": "AzureCli" + }, + "Parameters": { + "existingFoundryName": "", + "existingFoundryResourceGroup": "" + } +} +``` + +The AppHost calls `foundry.AsExisting(...)` with these parameters, so Aspire connects to the existing resource instead of provisioning a new one. + +### Option 2: Let Aspire provision a new Foundry resource + +Remove or comment out the `AsExisting` block in `DevUIIntegration.AppHost/Program.cs`: + +```csharp +// Comment the following lines to create a new Foundry instance +// _ = builder.AddParameterFromConfiguration("tenant", "Azure:TenantId"); +// var existingFoundryName = builder.AddParameter("existingFoundryName") ... +// foundry.AsExisting(existingFoundryName, existingFoundryResourceGroup); +``` + +Aspire will provision a new Azure AI Foundry resource on startup. The DevUI resource uses `.WaitFor(foundry)` transitively through the agent services, so the frontend won't become available until provisioning completes. This can take several minutes on first run. + +You still need to fill in the `Azure` section of `appsettings.json` (subscription, location, etc.) so Aspire knows where to create the resource. + +## Agent name matching with `WithAgentService` + +When connecting agent services to DevUI in the AppHost, you must pass the correct agent name via the `agents:` parameter. **This name must match the name used in `AddAIAgent(...)` inside each agent service's `Program.cs` — not the Aspire resource name.** + +For example, the WriterAgent Aspire resource is named `"writer-agent"`, but the agent is registered as `"writer"`: + +```csharp +// WriterAgent/Program.cs +builder.AddAIAgent("writer", "You write short stories ..."); +// ^^^^^^^^ this is the agent name +``` + +```csharp +// EditorAgent/Program.cs +builder.AddAIAgent("editor", (sp, key) => { ... }); +// ^^^^^^^^ this is the agent name +``` + +The AppHost must use these exact names: + +```csharp +// DevUIIntegration.AppHost/Program.cs +builder.AddDevUI("devui") + .WithAgentService(writerAgent, agents: [new("writer")]) // ✅ matches AddAIAgent("writer", ...) + .WithAgentService(editorAgent, agents: [new("editor")]) // ✅ matches AddAIAgent("editor", ...) + .WaitFor(writerAgent) + .WaitFor(editorAgent); +``` + +Using the wrong name (e.g., `new("writer-agent")` instead of `new("writer")`) will cause the aggregator to send an entity ID the backend doesn't recognize, resulting in 404 errors when interacting with the agent. + +If you omit the `agents:` parameter entirely, the aggregator defaults to a single agent named after the Aspire resource (e.g., `"writer-agent"`). Since agent services don't expose a `/v1/entities` discovery endpoint, **the Aspire resource name must exactly match the agent name registered via `AddAIAgent(...)` in the service's `Program.cs`**. + +## Running the sample + +```bash +cd dotnet/samples/05-end-to-end/DevUIAspireIntegration +aspire run +``` + +Once all services are running, open the **DevUI** URL shown in the Aspire dashboard. You should see both the writer and editor agents listed — select one and start a conversation. diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs new file mode 100644 index 0000000000..72f3215453 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Program.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.Identity; +using Microsoft.Agents.AI.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddServiceDefaults(); + +builder.AddAzureChatCompletionsClient(connectionName: "foundry", + configureSettings: settings => + { + settings.TokenCredential = new DefaultAzureCredential(); + settings.EnableSensitiveTelemetryData = builder.Environment.IsDevelopment(); + }) + .AddChatClient("gpt41"); + +builder.AddAIAgent("writer", "You write short stories (300 words or less) about the specified topic."); + +// Register services for OpenAI responses and conversations +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +// Map OpenAI API endpoints — DevUI aggregator routes requests here +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); + +app.MapDefaultEndpoints(); + +app.Run(); diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Properties/launchSettings.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Properties/launchSettings.json new file mode 100644 index 0000000000..5220475800 --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5280", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj new file mode 100644 index 0000000000..ef457ff1fb --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/WriterAgent/WriterAgent.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + a1b2c3d4-e5f6-7890-abcd-ef1234567890 + + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/DevUIAspireIntegration/aspire.config.json b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/aspire.config.json new file mode 100644 index 0000000000..d9ca439f8b --- /dev/null +++ b/dotnet/samples/05-end-to-end/DevUIAspireIntegration/aspire.config.json @@ -0,0 +1,5 @@ +{ + "appHost": { + "path": "DevUIIntegration.AppHost/DevUIIntegration.AppHost.csproj" + } +} \ No newline at end of file diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs new file mode 100644 index 0000000000..c308963fc1 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentEntityInfo.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Describes an AI agent exposed by an agent service backend, used for entity discovery in DevUI. +/// +/// +/// +/// When added via , +/// agent metadata is declared at the AppHost level so that the DevUI aggregator can build the +/// entity listing without querying each backend's /v1/entities endpoint. +/// +/// +/// Agent services only need to expose the standard OpenAI Responses and Conversations API endpoints +/// (MapOpenAIResponses and MapOpenAIConversations), not a custom discovery endpoint. +/// +/// +/// The unique identifier for the agent, typically matching the name passed to AddAIAgent. +/// A short description of the agent's capabilities. +public record AgentEntityInfo(string Id, string? Description = null) +{ + /// + /// Gets the display name for the agent. Defaults to if not specified. + /// + public string Name { get; init; } = Id; + + /// + /// Gets the entity type. Defaults to "agent". + /// + public string Type { get; init; } = "agent"; + + /// + /// Gets the framework identifier. Defaults to "agent_framework". + /// + public string Framework { get; init; } = "agent_framework"; +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs new file mode 100644 index 0000000000..7e7d5b16c0 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentFrameworkBuilderExtensions.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Aspire.Hosting.AgentFramework; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding Agent Framework DevUI resources to the application model. +/// +public static class AgentFrameworkBuilderExtensions +{ + /// + /// Adds a DevUI resource for testing AI agents in a distributed application. + /// + /// + /// + /// DevUI is a web-based interface for testing and debugging AI agents using the OpenAI Responses protocol. + /// When configured with , it aggregates agents from multiple backend services + /// and provides a unified testing interface. + /// + /// + /// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no external container image. + /// It serves the DevUI frontend from embedded resources in Microsoft.Agents.AI.DevUI when available, and + /// falls back to proxying from the first configured backend. It aggregates entity listings from all backends. + /// + /// + /// This resource is excluded from the deployment manifest as it is intended for development use only. + /// + /// + /// The . + /// The name to give the resource. + /// The host port for the DevUI web interface. If not specified, a random port will be assigned. + /// A reference to the for chaining. + /// + /// + /// var devui = builder.AddDevUI("devui") + /// .WithAgentService(dotnetAgent) + /// .WithAgentService(pythonAgent); + /// + /// + public static IResourceBuilder AddDevUI( + this IDistributedApplicationBuilder builder, + string name, + int? port = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(name); + + var resource = new DevUIResource(name, port); + + var resourceBuilder = builder.AddResource(resource) + .ExcludeFromManifest(); // DevUI is a dev-only tool + + // Initialize the in-process aggregator when the resource is initialized by the orchestrator + builder.Eventing.Subscribe(resource, async (e, ct) => + { + var logger = e.Logger; + var aggregator = new DevUIAggregatorHostedService(resource, e.Services.GetRequiredService().CreateLogger()); + + try + { + // Wait for dependencies (e.g. agent service backends) before starting. + // Custom resources must manually publish BeforeResourceStartedEvent to trigger + // the orchestrator's WaitFor mechanism. + await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct).ConfigureAwait(false); + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Starting + }).ConfigureAwait(false); + + await aggregator.StartAsync(ct).ConfigureAwait(false); + + // Allocate the endpoint so the URL appears in the Aspire dashboard + var endpointAnnotation = resource.Annotations + .OfType() + .First(ea => ea.Name == DevUIResource.PrimaryEndpointName); + + endpointAnnotation.AllocatedEndpoint = new AllocatedEndpoint( + endpointAnnotation, "localhost", aggregator.AllocatedPort); + + var devuiUrl = $"http://localhost:{aggregator.AllocatedPort}/devui/"; + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Running, + Urls = [new UrlSnapshot("DevUI", devuiUrl, IsInternal: false)] + }).ConfigureAwait(false); + + // Shut down the aggregator when the app stops + var lifetime = e.Services.GetRequiredService(); + lifetime.ApplicationStopping.Register(() => + { + e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.Finished + }).GetAwaiter().GetResult(); + + aggregator.StopAsync(CancellationToken.None).GetAwaiter().GetResult(); + aggregator.DisposeAsync().AsTask().GetAwaiter().GetResult(); + }); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start DevUI aggregator"); + + await aggregator.DisposeAsync().ConfigureAwait(false); + + await e.Notifications.PublishUpdateAsync(resource, snapshot => snapshot with + { + State = KnownResourceStates.FailedToStart + }).ConfigureAwait(false); + } + }); + + return resourceBuilder; + } + + /// + /// Configures DevUI to connect to an agent service backend. + /// + /// + /// + /// Each agent service should expose the OpenAI Responses and Conversations API endpoints + /// (via MapOpenAIResponses and MapOpenAIConversations). + /// + /// + /// When is provided, the aggregator builds the entity listing from + /// these declarations without querying the backend. When not provided, a single agent named + /// after the service resource is assumed. Agent services don't need a /v1/entities endpoint. + /// + /// + /// The type of the agent service resource. + /// The DevUI resource builder. + /// The agent service resource to connect to. + /// + /// Optional list of agents declared by this backend. When provided, the aggregator uses these + /// declarations directly. When not provided, defaults to a single agent named after the + /// resource. The backend doesn't need to expose a + /// /v1/entities endpoint in either case. + /// + /// + /// An optional prefix to add to entity IDs from this backend. + /// If not specified, the resource name will be used as the prefix. + /// + /// A reference to the for chaining. + /// + /// + /// var writerAgent = builder.AddProject<Projects.WriterAgent>("writer-agent"); + /// var editorAgent = builder.AddProject<Projects.EditorAgent>("editor-agent"); + /// + /// builder.AddDevUI("devui") + /// .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")]) + /// .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]) + /// .WaitFor(writerAgent) + /// .WaitFor(editorAgent); + /// + /// + public static IResourceBuilder WithAgentService( + this IResourceBuilder builder, + IResourceBuilder agentService, + IReadOnlyList? agents = null, + string? entityIdPrefix = null) + where TSource : IResourceWithEndpoints + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(agentService); + + // Default to a single agent named after the service resource + agents ??= [new AgentEntityInfo(agentService.Resource.Name)]; + + builder.WithAnnotation(new AgentServiceAnnotation(agentService.Resource, entityIdPrefix, agents)); + builder.WithRelationship(agentService.Resource, "agent-backend"); + + return builder; + } +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs new file mode 100644 index 0000000000..15b3f7dd90 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/AgentServiceAnnotation.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using Aspire.Hosting.AgentFramework; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// An annotation that tracks an agent service backend referenced by a DevUI resource. +/// +/// +/// This annotation is used to configure DevUI to aggregate entities from multiple +/// agent service backends. Each annotation represents one backend that DevUI should +/// connect to for entity discovery and request routing. +/// +public class AgentServiceAnnotation : IResourceAnnotation +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent service resource. + /// + /// An optional prefix to add to entity IDs from this backend to avoid conflicts. + /// If not specified, the resource name will be used as the prefix. + /// + /// + /// Optional list of agents declared by this backend. When provided, the aggregator builds the entity + /// listing directly from these declarations instead of querying the backend's /v1/entities endpoint. + /// + public AgentServiceAnnotation(IResource agentService, string? entityIdPrefix = null, IReadOnlyList? agents = null) + { + ArgumentNullException.ThrowIfNull(agentService); + + this.AgentService = agentService; + this.EntityIdPrefix = entityIdPrefix; + this.Agents = agents ?? []; + } + + /// + /// Gets the agent service resource that exposes AI agents. + /// + public IResource AgentService { get; } + + /// + /// Gets the prefix to use for entity IDs from this backend. + /// + /// + /// When null, the resource name will be used as the prefix. + /// Entity IDs will be formatted as "{prefix}/{entityId}" to ensure uniqueness + /// across multiple agent backends. + /// + public string? EntityIdPrefix { get; } + + /// + /// Gets the list of agents declared by this backend. + /// + /// + /// When non-empty, the DevUI aggregator uses these declarations to build the entity listing + /// without querying the backend. When empty, the aggregator falls back to calling + /// GET /v1/entities on the backend for discovery. + /// + public IReadOnlyList Agents { get; } +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj new file mode 100644 index 0000000000..0f45c95147 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj @@ -0,0 +1,25 @@ + + + + $(TargetFrameworksCore) + true + aspire integration hosting agent-framework devui ai agents + Microsoft Agent Framework DevUI support for Aspire. + + + $(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050 + + + + + + + + + + + + + + + diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs new file mode 100644 index 0000000000..d65efaca07 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIAggregatorHostedService.cs @@ -0,0 +1,779 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting.AgentFramework; + +/// +/// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends. +/// Serves the DevUI frontend directly from the Microsoft.Agents.AI.DevUI assembly's embedded +/// resources and intercepts API calls to provide multi-backend entity aggregation and request routing. +/// +internal sealed class DevUIAggregatorHostedService : IAsyncDisposable +{ + private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new(); + + private WebApplication? _app; + private readonly DevUIResource _resource; + private readonly ILogger _logger; + + // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) + private readonly Dictionary? _frontendResources; + + // Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context. + // Populated when the aggregator routes conversation requests to a positively-resolved backend. + private readonly ConcurrentDictionary _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase); + + public DevUIAggregatorHostedService( + DevUIResource resource, + ILogger logger) + { + this._resource = resource; + this._logger = logger; + this._frontendResources = LoadFrontendResources(logger); + } + + /// + /// Gets the port the aggregator is listening on, available after . + /// + internal int AllocatedPort { get; private set; } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + + builder.Services.AddHttpClient("devui-proxy") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false + }); + + this._app = builder.Build(); + + // Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation. + var port = this._resource.Port ?? 0; + this._app.Urls.Add($"http://127.0.0.1:{port}"); + this.MapRoutes(this._app); + + await this._app.StartAsync(cancellationToken).ConfigureAwait(false); + + var serverAddresses = this._app.Services.GetRequiredService() + .Features.Get(); + + if (serverAddresses is not null) + { + var address = serverAddresses.Addresses.First(); + var uri = new Uri(address); + this.AllocatedPort = uri.Port; + this._logger.LogInformation("DevUI aggregator started on port {Port}", this.AllocatedPort); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (this._app is not null) + { + await this._app.StopAsync(cancellationToken).ConfigureAwait(false); + } + } + + public async ValueTask DisposeAsync() + { + if (this._app is not null) + { + await this._app.DisposeAsync().ConfigureAwait(false); + this._app = null; + } + } + + /// + /// Loads the DevUI frontend resources from the Microsoft.Agents.AI.DevUI assembly. + /// The assembly embeds the Vite SPA build output as manifest resources. + /// Returns null if the assembly is not available. + /// + private static Dictionary? LoadFrontendResources(ILogger logger) + { + Assembly assembly; + try + { + assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends."); + return null; + } + + var prefix = $"{assembly.GetName().Name}.resources."; + var resources = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var name in assembly.GetManifestResourceNames()) + { + if (!name.StartsWith(prefix, StringComparison.Ordinal)) + { + continue; + } + + // The DevUI middleware maps resource names by replacing dots with slashes. + // Both the key and lookup use the same transform, so they match. + var key = name[prefix.Length..].Replace('.', '/'); + s_contentTypeProvider.TryGetContentType(name, out var contentType); + resources[key] = (name, contentType ?? "application/octet-stream"); + } + + if (resources.Count == 0) + { + logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources"); + return null; + } + + logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count); + return resources; + } + + /// + /// Serves the DevUI frontend. Uses embedded assembly resources if available, + /// otherwise falls back to proxying from the first backend agent service. + /// + private async Task ServeDevUIFrontendAsync(HttpContext context, string? path) + { + // Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly + if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/')) + { + var redirect = reqPath + "/"; + if (context.Request.QueryString.HasValue) + { + redirect += context.Request.QueryString.Value; + } + + context.Response.StatusCode = StatusCodes.Status301MovedPermanently; + context.Response.Headers.Location = redirect; + return; + } + + // Try embedded resources first + if (this._frontendResources is not null) + { + var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path; + + if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false)) + { + return; + } + + // SPA fallback: serve index.html for paths without a file extension (client-side routing) + if (!resourcePath.Contains('.', StringComparison.Ordinal) && + await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false)) + { + return; + } + + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + // Fallback: proxy from the first backend that serves /devui + var backends = this.ResolveBackends(); + var firstBackendUrl = backends.Values.FirstOrDefault(); + + if (firstBackendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.ContentType = "text/plain"; + await context.Response.WriteAsync( + "DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false); + return; + } + + var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}"; + await ProxyRequestAsync( + context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false); + } + + private async Task TryServeResourceAsync(HttpContext context, string resourcePath) + { + if (this._frontendResources is null) + { + return false; + } + + var key = resourcePath.Replace('.', '/'); + + if (!this._frontendResources.TryGetValue(key, out var entry)) + { + return false; + } + + Assembly assembly; + try + { + assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); + } + catch + { + return false; + } + + using var stream = assembly.GetManifestResourceStream(entry.ResourceName); + + if (stream is null) + { + return false; + } + + context.Response.ContentType = entry.ContentType; + context.Response.Headers.CacheControl = "no-cache, no-store"; + await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + return true; + } + + private static IResult GetMeta() + { + return Results.Json(new + { + ui_mode = "developer", + version = "0.1.0", + framework = "agent_framework", + runtime = "dotnet", + capabilities = new Dictionary + { + ["tracing"] = false, + ["openai_proxy"] = false, + ["deployment"] = false + }, + auth_required = false + }); + } + + private void MapRoutes(WebApplication app) + { + app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); + + // Intercept API calls for multi-backend aggregation and routing + app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync); + app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync); + app.MapPost("/v1/responses", this.RouteResponsesAsync); + app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync); + app.MapGet("/meta", GetMeta); + + // Serve the DevUI frontend from embedded assembly resources + app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync); + } + + /// + /// Resolves backend URLs from the resource's annotations. + /// This method does not cache results to ensure late-allocated backends are always discovered. + /// + private Dictionary ResolveBackends() + { + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var annotation in this._resource.Annotations.OfType()) + { + if (annotation.AgentService is not IResourceWithEndpoints rwe) + { + continue; + } + + var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; + + try + { + var endpoint = rwe.GetEndpoint("http"); + if (endpoint.IsAllocated) + { + result[prefix] = endpoint.Url; + } + } + catch (Exception ex) + { + this._logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix); + } + } + + return result; + } + + private async Task AggregateEntitiesAsync(HttpContext context) + { + var backends = this.ResolveBackends(); + var allEntities = new JsonArray(); + + foreach (var annotation in this._resource.Annotations.OfType()) + { + var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; + + if (annotation.Agents.Count > 0) + { + // Build entities from AppHost-declared metadata — no backend call needed + foreach (var agent in annotation.Agents) + { + allEntities.Add(new JsonObject + { + ["id"] = $"{prefix}/{agent.Id}", + ["type"] = agent.Type, + ["name"] = agent.Name, + ["description"] = agent.Description, + ["framework"] = agent.Framework, + ["_original_id"] = agent.Id, + ["_backend"] = prefix + }); + } + + continue; + } + + // Fallback: query backend /v1/entities for discovery + if (!backends.TryGetValue(prefix, out var baseUrl)) + { + continue; + } + + try + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + var response = await client.GetAsync( + new Uri(new Uri(baseUrl), "/v1/entities"), + context.RequestAborted).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + this._logger.LogWarning( + "Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}", + prefix, baseUrl, response.StatusCode); + continue; + } + + var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false); + var doc = JsonNode.Parse(json); + var entities = doc?["entities"]?.AsArray(); + + if (entities is null) + { + continue; + } + + foreach (var entity in entities) + { + if (entity is null) + { + continue; + } + + var cloned = entity.DeepClone(); + var id = cloned["id"]?.GetValue() ?? cloned["name"]?.GetValue(); + + if (id is not null) + { + cloned["id"] = $"{prefix}/{id}"; + cloned["_original_id"] = id; + cloned["_backend"] = prefix; + } + + allEntities.Add(cloned); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + this._logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl); + } + } + + return Results.Json(new { entities = allEntities }); + } + + private async Task RouteEntityInfoAsync(HttpContext context, string entityPath) + { + var (backendUrl, actualPath) = this.ResolveBackend(entityPath); + + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}"); + + using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false); + await CopyResponseAsync(response, context).ConfigureAwait(false); + } + + private async Task RouteResponsesAsync(HttpContext context) + { + var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); + var json = JsonNode.Parse(bodyBytes); + var entityId = json?["metadata"]?["entity_id"]?.GetValue(); + + if (entityId is null) + { + var firstBackend = this.ResolveBackends().Values.FirstOrDefault(); + if (firstBackend is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false); + return; + } + + var (backendUrl, actualEntityId) = this.ResolveBackend(entityId); + + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsJsonAsync( + new { error = $"No backend found for entity '{entityId}'" }, + context.RequestAborted).ConfigureAwait(false); + return; + } + + // Rewrite entity_id to the un-prefixed original value + json!["metadata"]!["entity_id"] = actualEntityId; + var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json); + + await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false); + } + + private async Task ProxyConversationsAsync(HttpContext context, string? path) + { + // Try to determine the backend from agent_id query param or request body + string? backendUrl = null; + string? actualAgentId = null; + + var agentId = context.Request.Query["agent_id"].FirstOrDefault(); + if (agentId is not null) + { + (backendUrl, actualAgentId) = this.ResolveBackend(agentId); + } + + // Build query string with rewritten agent_id if we resolved from query param + var queryString = (agentId is not null && actualAgentId is not null) + ? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId) + : context.Request.QueryString.ToString(); + + // Try conversation→backend map for previously-seen conversations + if (backendUrl is null) + { + var conversationId = ExtractConversationId(path); + if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl)) + { + backendUrl = mappedUrl; + } + } + + // Always read the request body when present so it isn't dropped during proxying + byte[]? bodyBytes = null; + if (context.Request.ContentLength > 0) + { + bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); + } + + // Try to resolve backend from request body metadata when not yet determined + if (backendUrl is null && bodyBytes is not null) + { + var json = JsonNode.Parse(bodyBytes); + var entityId = json?["metadata"]?["entity_id"]?.GetValue() + ?? json?["metadata"]?["agent_id"]?.GetValue(); + + if (entityId is not null) + { + string actualId; + (backendUrl, actualId) = this.ResolveBackend(entityId); + + if (backendUrl is not null) + { + // Rewrite the entity/agent id to the un-prefixed value + if (json?["metadata"]?["entity_id"] is not null) + { + json!["metadata"]!["entity_id"] = actualId; + } + + if (json?["metadata"]?["agent_id"] is not null) + { + json!["metadata"]!["agent_id"] = actualId; + } + + bodyBytes = JsonSerializer.SerializeToUtf8Bytes(json); + var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + + // Also rewrite query string agent_id if present + var bodyQueryString = (agentId is not null) + ? RewriteAgentIdInQueryString(context.Request.QueryString, actualId) + : context.Request.QueryString.ToString(); + + await this.ProxyAndRecordConversationAsync( + context, backendUrl, path, targetPath + bodyQueryString, bodyBytes).ConfigureAwait(false); + return; + } + } + + // Couldn't determine backend from body; proxy raw bytes to first backend + backendUrl = this.ResolveBackends().Values.FirstOrDefault(); + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + await ProxyRequestAsync( + context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false); + return; + } + + // Route to resolved backend (from query or conversation map), or fall back to first backend + var backendKnown = backendUrl is not null; + backendUrl ??= this.ResolveBackends().Values.FirstOrDefault(); + if (backendUrl is null) + { + context.Response.StatusCode = StatusCodes.Status502BadGateway; + return; + } + + var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; + if (backendKnown) + { + await this.ProxyAndRecordConversationAsync( + context, backendUrl, path, convPath + queryString, bodyBytes).ConfigureAwait(false); + } + else + { + await ProxyRequestAsync( + context, backendUrl, convPath + queryString, bodyBytes).ConfigureAwait(false); + } + } + + /// + /// Rewrites the agent_id query parameter to the un-prefixed value for backend routing. + /// + internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId) + { + if (!queryString.HasValue) + { + return string.Empty; + } + + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value); + query["agent_id"] = actualAgentId; + + return QueryString.Create(query).ToString(); + } + + private static string? ExtractConversationId(string? path) + { + if (string.IsNullOrEmpty(path)) + { + return null; + } + + var slashIndex = path.IndexOf('/'); + return slashIndex > 0 ? path[..slashIndex] : path; + } + + /// + /// Records the conversation→backend mapping and proxies the request. + /// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID. + /// + private async Task ProxyAndRecordConversationAsync( + HttpContext context, + string backendUrl, + string? conversationPath, + string targetUrl, + byte[]? bodyBytes) + { + var conversationId = ExtractConversationId(conversationPath); + if (conversationId is not null) + { + // We already know the conversation ID — record and proxy normally + this._conversationBackendMap[conversationId] = backendUrl; + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + return; + } + + // Creation POST: intercept response to capture the new conversation ID + if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + { + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + return; + } + + var originalBody = context.Response.Body; + using var buffer = new MemoryStream(); + context.Response.Body = buffer; + + try + { + await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); + + if (context.Response.StatusCode is >= 200 and < 300) + { + buffer.Position = 0; + try + { + using var doc = await JsonDocument.ParseAsync( + buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false); + if (doc.RootElement.TryGetProperty("id", out var idProp) && + idProp.ValueKind == JsonValueKind.String) + { + var createdId = idProp.GetString(); + if (createdId is not null) + { + this._conversationBackendMap[createdId] = backendUrl; + this._logger.LogDebug( + "Recorded conversation '{ConversationId}' → backend '{BackendUrl}'", + createdId, backendUrl); + } + } + } + catch + { + // Best-effort: response may not be parseable JSON + } + } + } + finally + { + context.Response.Body = originalBody; + buffer.Position = 0; + await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false); + } + } + + private static async Task ProxyRequestAsync( + HttpContext context, + string backendUrl, + string path, + byte[]? bodyBytes, + bool streaming = false) + { + var httpClientFactory = context.RequestServices.GetRequiredService(); + using var client = httpClientFactory.CreateClient("devui-proxy"); + + var targetUri = new Uri(new Uri(backendUrl), path); + using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri); + + foreach (var header in context.Request.Headers) + { + if (IsHopByHopHeader(header.Key)) + { + continue; + } + + request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + + if (bodyBytes is not null) + { + request.Content = new ByteArrayContent(bodyBytes); + if (context.Request.ContentType is not null) + { + request.Content.Headers.ContentType = + System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType); + } + } + + var completionOption = streaming + ? HttpCompletionOption.ResponseHeadersRead + : HttpCompletionOption.ResponseContentRead; + + using var response = await client.SendAsync( + request, completionOption, context.RequestAborted).ConfigureAwait(false); + + if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream") + { + context.Response.StatusCode = (int)response.StatusCode; + context.Response.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache"; + + using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false); + await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); + } + else + { + await CopyResponseAsync(response, context).ConfigureAwait(false); + } + } + + private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId) + { + var backends = this.ResolveBackends(); + var slashIndex = prefixedId.IndexOf('/'); + + if (slashIndex > 0) + { + var prefix = prefixedId[..slashIndex]; + var rest = prefixedId[(slashIndex + 1)..]; + + if (backends.TryGetValue(prefix, out var url)) + { + return (url, rest); + } + } + + // Fallback: check all prefixes + foreach (var (prefix, url) in backends) + { + if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal)) + { + return (url, prefixedId[(prefix.Length + 1)..]); + } + } + + return (null, prefixedId); + } + + private static async Task ReadRequestBodyAsync(HttpRequest request) + { + using var ms = new MemoryStream(); + await request.Body.CopyToAsync(ms).ConfigureAwait(false); + return ms.ToArray(); + } + + private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context) + { + context.Response.StatusCode = (int)response.StatusCode; + + foreach (var header in response.Headers.Where(h => !IsHopByHopHeader(h.Key))) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + foreach (var header in response.Content.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false); + } + + private static bool IsHopByHopHeader(string headerName) + { + return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase) + || headerName.Equals("Host", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs new file mode 100644 index 0000000000..9cf85dff07 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/DevUIResource.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Sockets; + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a DevUI resource for testing AI agents in a distributed application. +/// +/// +/// DevUI aggregates agents from multiple backend services and provides a unified +/// web interface for testing and debugging AI agents using the OpenAI Responses protocol. +/// The aggregator runs as an in-process reverse proxy within the AppHost, requiring no +/// external container image. +/// +/// The name of the DevUI resource. +public class DevUIResource(string name) : Resource(name), IResourceWithEndpoints, IResourceWithWaitSupport +{ + internal const string PrimaryEndpointName = "http"; + + /// + /// Initializes a new instance of the class with endpoint annotations. + /// + /// The name of the resource. + /// An optional fixed port. If null, a dynamic port is assigned. + internal DevUIResource(string name, int? port) : this(name) + { + this.Port = port; + this.Annotations.Add(new EndpointAnnotation( + ProtocolType.Tcp, + uriScheme: "http", + name: PrimaryEndpointName, + port: port, + isProxied: false) + { + TargetHost = "localhost" + }); + } + + /// + /// Gets the optional fixed port for the DevUI web interface. + /// + internal int? Port { get; } + + /// + /// Gets the primary HTTP endpoint for the DevUI web interface. + /// + public EndpointReference PrimaryEndpoint => field ??= new(this, PrimaryEndpointName); +} diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md new file mode 100644 index 0000000000..8dbace2514 --- /dev/null +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/README.md @@ -0,0 +1,104 @@ +# Aspire.Hosting.AgentFramework.DevUI library + +Provides extension methods and resource definitions for an Aspire AppHost to configure a DevUI resource for testing and debugging AI agents built with [Microsoft Agent Framework](https://github.com/microsoft/agent-framework). + +## Getting started + +### Prerequisites + +Agent services must expose the OpenAI Responses and Conversations API endpoints. This is compatible with services using [Microsoft Agent Framework](https://github.com/microsoft/agent-framework) with `MapOpenAIResponses()` and `MapOpenAIConversations()` mapped. + +### Install the package + +In your AppHost project, install the Aspire Agent Framework DevUI Hosting library with [NuGet](https://www.nuget.org): + +```dotnetcli +dotnet add package Aspire.Hosting.AgentFramework.DevUI +``` + +## Usage example + +Then, in the _AppHost.cs_ file of `AppHost`, add a DevUI resource and connect it to your agent services using the following methods: + +```csharp +var writerAgent = builder.AddProject("writer-agent") + .WithHttpHealthCheck("/health"); + +var editorAgent = builder.AddProject("editor-agent") + .WithHttpHealthCheck("/health"); + +var devui = builder.AddDevUI("devui") + .WithAgentService(writerAgent) + .WithAgentService(editorAgent) + .WaitFor(writerAgent) + .WaitFor(editorAgent); +``` + +Each agent service only needs to map the standard OpenAI API endpoints — no custom discovery endpoints are required: + +```csharp +// In the agent service's Program.cs +builder.AddAIAgent("writer", "You write short stories."); +builder.Services.AddOpenAIResponses(); +builder.Services.AddOpenAIConversations(); + +var app = builder.Build(); + +app.MapOpenAIResponses(); +app.MapOpenAIConversations(); +``` + +## How it works + +`AddDevUI` starts an **in-process aggregator** inside the AppHost — no external container image is needed. The aggregator is a lightweight Kestrel server that: + +1. **Serves the DevUI frontend** from the `Microsoft.Agents.AI.DevUI` assembly's embedded resources (loaded at runtime). If the assembly is not available, it falls back to proxying the frontend from the first backend. +2. **Aggregates entities** from all configured agent service backends into a single `/v1/entities` listing. Each entity ID is prefixed with the backend name to ensure uniqueness across services (e.g., `writer-agent/writer`, `editor-agent/editor`). +3. **Routes requests** to the correct backend based on the entity ID prefix. When DevUI sends a `POST /v1/responses` or `/v1/conversations` request, the aggregator strips the prefix and forwards it to the appropriate service. +4. **Streams SSE responses** for the `/v1/responses` endpoint, so agent responses stream back to the DevUI frontend in real time. + +The aggregator publishes its URL to the Aspire dashboard, where it appears as a clickable link. + +## Agent discovery + +By default, `WithAgentService` declares a single agent named after the Aspire resource. You can provide explicit agent metadata when the agent name differs from the resource name, or when a service hosts multiple agents: + +```csharp +builder.AddDevUI("devui") + .WithAgentService(writerAgent, agents: [new("writer", "Writes short stories")]) + .WithAgentService(editorAgent, agents: [new("editor", "Edits and formats stories")]); +``` + +Agent metadata is declared at the AppHost level so the aggregator builds the entity listing directly — agent services don't need a `/v1/entities` endpoint. + +## Configuration + +### Custom entity ID prefix + +By default, entity IDs are prefixed with the Aspire resource name. You can specify a custom prefix: + +```csharp +builder.AddDevUI("devui") + .WithAgentService(myService, entityIdPrefix: "custom-prefix"); +``` + +### Custom port + +You can specify a fixed host port for the DevUI web interface: + +```csharp +builder.AddDevUI("devui", port: 8090); +``` + +### DevUI frontend assembly + +To serve the DevUI frontend directly from the aggregator (instead of proxying from a backend), add the `Microsoft.Agents.AI.DevUI` NuGet package to your AppHost project. The aggregator loads its embedded resources at runtime via `Assembly.Load`. + +## Additional documentation + +* https://github.com/microsoft/agent-framework +* https://github.com/microsoft/agent-framework/tree/main/dotnet/src/Microsoft.Agents.AI.DevUI + +## Feedback & contributing + +https://github.com/dotnet/aspire diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs new file mode 100644 index 0000000000..84273d6891 --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentEntityInfoTests.cs @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the record. +/// +public class AgentEntityInfoTests +{ + #region Constructor Tests + + /// + /// Verifies that the Id property is set from the constructor parameter. + /// + [Fact] + public void Constructor_WithId_SetsIdProperty() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent"); + + // Assert + Assert.Equal("test-agent", info.Id); + } + + /// + /// Verifies that the Description property is set when provided. + /// + [Fact] + public void Constructor_WithDescription_SetsDescriptionProperty() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent", "A test agent"); + + // Assert + Assert.Equal("A test agent", info.Description); + } + + /// + /// Verifies that the Description property is null when not provided. + /// + [Fact] + public void Constructor_WithoutDescription_DescriptionIsNull() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent"); + + // Assert + Assert.Null(info.Description); + } + + #endregion + + #region Default Value Tests + + /// + /// Verifies that Name defaults to the Id value when not explicitly set. + /// + [Fact] + public void Name_NotSet_DefaultsToId() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent"); + + // Assert + Assert.Equal("test-agent", info.Name); + } + + /// + /// Verifies that Name can be overridden with a custom value. + /// + [Fact] + public void Name_Set_ReturnsCustomValue() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent") { Name = "Custom Name" }; + + // Assert + Assert.Equal("Custom Name", info.Name); + } + + /// + /// Verifies that Type defaults to "agent". + /// + [Fact] + public void Type_NotSet_DefaultsToAgent() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent"); + + // Assert + Assert.Equal("agent", info.Type); + } + + /// + /// Verifies that Type can be overridden with a custom value. + /// + [Fact] + public void Type_Set_ReturnsCustomValue() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent") { Type = "workflow" }; + + // Assert + Assert.Equal("workflow", info.Type); + } + + /// + /// Verifies that Framework defaults to "agent_framework". + /// + [Fact] + public void Framework_NotSet_DefaultsToAgentFramework() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent"); + + // Assert + Assert.Equal("agent_framework", info.Framework); + } + + /// + /// Verifies that Framework can be overridden with a custom value. + /// + [Fact] + public void Framework_Set_ReturnsCustomValue() + { + // Arrange & Act + var info = new AgentEntityInfo("test-agent") { Framework = "custom_framework" }; + + // Assert + Assert.Equal("custom_framework", info.Framework); + } + + #endregion + + #region Record Equality Tests + + /// + /// Verifies that two AgentEntityInfo records with identical values are equal. + /// + [Fact] + public void Equality_SameValues_AreEqual() + { + // Arrange + var info1 = new AgentEntityInfo("agent", "description"); + var info2 = new AgentEntityInfo("agent", "description"); + + // Assert + Assert.Equal(info1, info2); + } + + /// + /// Verifies that two AgentEntityInfo records with different Ids are not equal. + /// + [Fact] + public void Equality_DifferentIds_AreNotEqual() + { + // Arrange + var info1 = new AgentEntityInfo("agent1"); + var info2 = new AgentEntityInfo("agent2"); + + // Assert + Assert.NotEqual(info1, info2); + } + + /// + /// Verifies that with-expression creates a modified copy. + /// + [Fact] + public void WithExpression_ModifiesProperty_CreatesNewInstance() + { + // Arrange + var original = new AgentEntityInfo("agent", "Original description"); + + // Act + var modified = original with { Description = "Modified description" }; + + // Assert + Assert.Equal("Original description", original.Description); + Assert.Equal("Modified description", modified.Description); + Assert.Equal(original.Id, modified.Id); + } + + #endregion +} diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs new file mode 100644 index 0000000000..21699e3d64 --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentFrameworkBuilderExtensionsTests.cs @@ -0,0 +1,567 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Aspire.Hosting.ApplicationModel; +using Moq; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AgentFrameworkBuilderExtensionsTests +{ + #region AddDevUI Validation Tests + + /// + /// Verifies that AddDevUI throws ArgumentNullException when builder is null. + /// + [Fact] + public void AddDevUI_NullBuilder_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws( + () => AgentFrameworkBuilderExtensions.AddDevUI(null!, "devui")); + Assert.Equal("builder", exception.ParamName); + } + + /// + /// Verifies that AddDevUI throws ArgumentNullException when name is null. + /// + [Fact] + public void AddDevUI_NullName_ThrowsArgumentNullException() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act & Assert + var exception = Assert.Throws( + () => builder.AddDevUI(null!)); + Assert.Equal("name", exception.ParamName); + } + + /// + /// Verifies that AddDevUI creates a resource with the specified name. + /// + [Fact] + public void AddDevUI_ValidName_CreatesResourceWithName() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act + var resourceBuilder = builder.AddDevUI("my-devui"); + + // Assert + Assert.Equal("my-devui", resourceBuilder.Resource.Name); + } + + /// + /// Verifies that AddDevUI creates a DevUIResource. + /// + [Fact] + public void AddDevUI_ReturnsDevUIResourceBuilder() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act + var resourceBuilder = builder.AddDevUI("devui"); + + // Assert + Assert.IsType(resourceBuilder.Resource); + } + + /// + /// Verifies that AddDevUI with port configures the endpoint. + /// + [Fact] + public void AddDevUI_WithPort_ConfiguresEndpointWithPort() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act + var resourceBuilder = builder.AddDevUI("devui", port: 8090); + + // Assert + var endpoint = resourceBuilder.Resource.Annotations + .OfType() + .FirstOrDefault(e => e.Name == "http"); + Assert.NotNull(endpoint); + Assert.Equal(8090, endpoint.Port); + } + + /// + /// Verifies that AddDevUI without port leaves port as null for dynamic allocation. + /// + [Fact] + public void AddDevUI_WithoutPort_EndpointHasDynamicPort() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + + // Act + var resourceBuilder = builder.AddDevUI("devui"); + + // Assert + var endpoint = resourceBuilder.Resource.Annotations + .OfType() + .FirstOrDefault(e => e.Name == "http"); + Assert.NotNull(endpoint); + Assert.Null(endpoint.Port); + } + + #endregion + + #region WithAgentService Validation Tests + + /// + /// Verifies that WithAgentService throws ArgumentNullException when builder is null. + /// + [Fact] + public void WithAgentService_NullBuilder_ThrowsArgumentNullException() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var mockAgentService = CreateMockAgentServiceBuilder(appBuilder, "agent-service"); + + // Act & Assert + var exception = Assert.Throws( + () => AgentFrameworkBuilderExtensions.WithAgentService(null!, mockAgentService)); + Assert.Equal("builder", exception.ParamName); + } + + /// + /// Verifies that WithAgentService throws ArgumentNullException when agentService is null. + /// + [Fact] + public void WithAgentService_NullAgentService_ThrowsArgumentNullException() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + + // Act & Assert + var exception = Assert.Throws( + () => devuiBuilder.WithAgentService(null!)); + Assert.Equal("agentService", exception.ParamName); + } + + #endregion + + #region WithAgentService Annotation Tests + + /// + /// Verifies that WithAgentService adds an AgentServiceAnnotation to the resource. + /// + [Fact] + public void WithAgentService_ValidService_AddsAnnotation() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devuiBuilder.WithAgentService(agentService); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .FirstOrDefault(); + Assert.NotNull(annotation); + Assert.Same(agentService.Resource, annotation.AgentService); + } + + /// + /// Verifies that WithAgentService defaults to agent name being the resource name. + /// + [Fact] + public void WithAgentService_NoAgents_DefaultsToResourceNameAsAgent() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devuiBuilder.WithAgentService(agentService); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + Assert.Single(annotation.Agents); + Assert.Equal("writer-agent", annotation.Agents[0].Id); + } + + /// + /// Verifies that WithAgentService with explicit agents uses those agents. + /// + [Fact] + public void WithAgentService_WithAgents_UsesProvidedAgents() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "multi-agent-service"); + var agents = new[] + { + new AgentEntityInfo("agent1", "First agent"), + new AgentEntityInfo("agent2", "Second agent") + }; + + // Act + devuiBuilder.WithAgentService(agentService, agents: agents); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + Assert.Equal(2, annotation.Agents.Count); + Assert.Equal("agent1", annotation.Agents[0].Id); + Assert.Equal("agent2", annotation.Agents[1].Id); + } + + /// + /// Verifies that WithAgentService with custom prefix uses that prefix. + /// + [Fact] + public void WithAgentService_WithEntityIdPrefix_UsesProvidedPrefix() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devuiBuilder.WithAgentService(agentService, entityIdPrefix: "custom-prefix"); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + Assert.Equal("custom-prefix", annotation.EntityIdPrefix); + } + + /// + /// Verifies that WithAgentService without prefix leaves EntityIdPrefix null. + /// + [Fact] + public void WithAgentService_NoEntityIdPrefix_PrefixIsNull() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devuiBuilder.WithAgentService(agentService); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + Assert.Null(annotation.EntityIdPrefix); + } + + #endregion + + #region Chaining Tests + + /// + /// Verifies that WithAgentService returns the builder for chaining. + /// + [Fact] + public void WithAgentService_ReturnsSameBuilder_ForChaining() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + var result = devuiBuilder.WithAgentService(agentService); + + // Assert + Assert.Same(devuiBuilder, result); + } + + /// + /// Verifies that multiple WithAgentService calls can be chained. + /// + [Fact] + public void WithAgentService_MultipleCalls_AddsMultipleAnnotations() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent"); + + // Act + devuiBuilder + .WithAgentService(writerService) + .WithAgentService(editorService); + + // Assert + var annotations = devuiBuilder.Resource.Annotations + .OfType() + .ToList(); + Assert.Equal(2, annotations.Count); + Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent"); + Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent"); + } + + /// + /// Verifies that AddDevUI returns a builder that can be chained with WithAgentService. + /// + [Fact] + public void AddDevUI_CanChainWithAgentService() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act - Chain AddDevUI with WithAgentService + var result = appBuilder.AddDevUI("devui").WithAgentService(agentService); + + // Assert + Assert.NotNull(result); + var annotation = result.Resource.Annotations + .OfType() + .FirstOrDefault(); + Assert.NotNull(annotation); + } + + #endregion + + #region Relationship Tests + + /// + /// Verifies that WithAgentService creates a relationship annotation. + /// + [Fact] + public void WithAgentService_CreatesRelationshipAnnotation() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devuiBuilder.WithAgentService(agentService); + + // Assert + var relationship = devuiBuilder.Resource.Annotations + .OfType() + .FirstOrDefault(); + Assert.NotNull(relationship); + Assert.Equal("agent-backend", relationship.Type); + } + + /// + /// Verifies that multiple WithAgentService calls create multiple relationship annotations. + /// + [Fact] + public void WithAgentService_MultipleCalls_CreatesMultipleRelationships() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var writerService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + var editorService = CreateMockAgentServiceBuilder(appBuilder, "editor-agent"); + + // Act + devuiBuilder + .WithAgentService(writerService) + .WithAgentService(editorService); + + // Assert + var relationships = devuiBuilder.Resource.Annotations + .OfType() + .ToList(); + Assert.Equal(2, relationships.Count); + Assert.All(relationships, r => Assert.Equal("agent-backend", r.Type)); + } + + #endregion + + #region Agent Metadata Tests + + /// + /// Verifies that agent description is preserved when specified. + /// + [Fact] + public void WithAgentService_AgentWithDescription_PreservesDescription() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + var agents = new[] { new AgentEntityInfo("writer", "Writes creative stories") }; + + // Act + devuiBuilder.WithAgentService(agentService, agents: agents); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + Assert.Equal("Writes creative stories", annotation.Agents[0].Description); + } + + /// + /// Verifies that custom agent properties are preserved. + /// + [Fact] + public void WithAgentService_CustomAgentProperties_ArePreserved() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "custom-service"); + var agents = new[] + { + new AgentEntityInfo("custom-agent") + { + Name = "Custom Display Name", + Type = "workflow", + Framework = "custom_framework" + } + }; + + // Act + devuiBuilder.WithAgentService(agentService, agents: agents); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + var agent = annotation.Agents[0]; + Assert.Equal("custom-agent", agent.Id); + Assert.Equal("Custom Display Name", agent.Name); + Assert.Equal("workflow", agent.Type); + Assert.Equal("custom_framework", agent.Framework); + } + + /// + /// Verifies that empty agents array can be explicitly provided and is respected. + /// + [Fact] + public void WithAgentService_EmptyAgentsArray_UsesEmptyArray() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devuiBuilder = appBuilder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + var emptyAgents = Array.Empty(); + + // Act + devuiBuilder.WithAgentService(agentService, agents: emptyAgents); + + // Assert + var annotation = devuiBuilder.Resource.Annotations + .OfType() + .First(); + // When explicitly passing an empty array, the extension method respects it + // This is the expected behavior - explicit empty means "discover at runtime" + Assert.Empty(annotation.Agents); + } + + #endregion + + #region Edge Case Tests + + /// + /// Verifies that AddDevUI can be called multiple times with different names. + /// + [Fact] + public void AddDevUI_MultipleCalls_CreatesSeparateResources() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + + // Act + var devui1 = appBuilder.AddDevUI("devui1"); + var devui2 = appBuilder.AddDevUI("devui2"); + + // Assert + Assert.NotSame(devui1.Resource, devui2.Resource); + Assert.Equal("devui1", devui1.Resource.Name); + Assert.Equal("devui2", devui2.Resource.Name); + } + + /// + /// Verifies that same agent service can be added to multiple DevUI resources. + /// + [Fact] + public void WithAgentService_SameServiceToMultipleDevUI_Works() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devui1 = appBuilder.AddDevUI("devui1"); + var devui2 = appBuilder.AddDevUI("devui2"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "shared-agent"); + + // Act + devui1.WithAgentService(agentService); + devui2.WithAgentService(agentService); + + // Assert + var annotation1 = devui1.Resource.Annotations.OfType().Single(); + var annotation2 = devui2.Resource.Annotations.OfType().Single(); + Assert.Same(annotation1.AgentService, annotation2.AgentService); + } + + /// + /// Verifies that WithAgentService works with different entity ID prefixes for the same service. + /// + [Fact] + public void WithAgentService_DifferentPrefixesToDifferentDevUI_Works() + { + // Arrange + var appBuilder = DistributedApplication.CreateBuilder(); + var devui1 = appBuilder.AddDevUI("devui1"); + var devui2 = appBuilder.AddDevUI("devui2"); + var agentService = CreateMockAgentServiceBuilder(appBuilder, "writer-agent"); + + // Act + devui1.WithAgentService(agentService, entityIdPrefix: "prefix1"); + devui2.WithAgentService(agentService, entityIdPrefix: "prefix2"); + + // Assert + var annotation1 = devui1.Resource.Annotations.OfType().Single(); + var annotation2 = devui2.Resource.Annotations.OfType().Single(); + Assert.Equal("prefix1", annotation1.EntityIdPrefix); + Assert.Equal("prefix2", annotation2.EntityIdPrefix); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a mock agent service builder for testing. + /// Uses a minimal resource implementation that satisfies IResourceWithEndpoints. + /// + private static IResourceBuilder CreateMockAgentServiceBuilder( + IDistributedApplicationBuilder appBuilder, + string name) + { + // Create a mock resource that implements IResourceWithEndpoints + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns(name); + mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection()); + + var mockBuilder = new Mock>(); + mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object); + mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder); + + return mockBuilder.Object; + } + + #endregion +} diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs new file mode 100644 index 0000000000..0e297c56bf --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/AgentServiceAnnotationTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Aspire.Hosting.ApplicationModel; +using Moq; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class AgentServiceAnnotationTests +{ + #region Constructor Validation Tests + + /// + /// Verifies that passing null for agentService throws ArgumentNullException. + /// + [Fact] + public void Constructor_NullAgentService_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => new AgentServiceAnnotation(null!)); + } + + /// + /// Verifies that a valid agentService can be used to create the annotation. + /// + [Fact] + public void Constructor_ValidAgentService_CreatesAnnotation() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("test-service"); + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object); + + // Assert + Assert.NotNull(annotation); + Assert.Same(mockResource.Object, annotation.AgentService); + } + + #endregion + + #region Property Tests + + /// + /// Verifies that AgentService property returns the value passed to constructor. + /// + [Fact] + public void AgentService_ReturnsConstructorValue() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("my-service"); + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object); + + // Assert + Assert.Same(mockResource.Object, annotation.AgentService); + } + + /// + /// Verifies that EntityIdPrefix returns null when not specified. + /// + [Fact] + public void EntityIdPrefix_NotSpecified_ReturnsNull() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("test-service"); + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object); + + // Assert + Assert.Null(annotation.EntityIdPrefix); + } + + /// + /// Verifies that EntityIdPrefix returns the value passed to constructor. + /// + [Fact] + public void EntityIdPrefix_Specified_ReturnsValue() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("test-service"); + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object, entityIdPrefix: "custom-prefix"); + + // Assert + Assert.Equal("custom-prefix", annotation.EntityIdPrefix); + } + + /// + /// Verifies that Agents returns empty collection when not specified. + /// + [Fact] + public void Agents_NotSpecified_ReturnsEmptyCollection() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("test-service"); + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object); + + // Assert + Assert.NotNull(annotation.Agents); + Assert.Empty(annotation.Agents); + } + + /// + /// Verifies that Agents returns the list passed to constructor. + /// + [Fact] + public void Agents_Specified_ReturnsValue() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("test-service"); + var agents = new[] { new AgentEntityInfo("agent1"), new AgentEntityInfo("agent2") }; + + // Act + var annotation = new AgentServiceAnnotation(mockResource.Object, agents: agents); + + // Assert + Assert.Equal(2, annotation.Agents.Count); + Assert.Equal("agent1", annotation.Agents[0].Id); + Assert.Equal("agent2", annotation.Agents[1].Id); + } + + #endregion + + #region Full Constructor Tests + + /// + /// Verifies that all constructor parameters are correctly stored. + /// + [Fact] + public void Constructor_AllParameters_SetsAllProperties() + { + // Arrange + var mockResource = new Mock(); + mockResource.Setup(r => r.Name).Returns("full-service"); + var agents = new[] { new AgentEntityInfo("writer", "Writes stories") }; + + // Act + var annotation = new AgentServiceAnnotation( + mockResource.Object, + entityIdPrefix: "writer-backend", + agents: agents); + + // Assert + Assert.Same(mockResource.Object, annotation.AgentService); + Assert.Equal("writer-backend", annotation.EntityIdPrefix); + Assert.Single(annotation.Agents); + Assert.Equal("writer", annotation.Agents[0].Id); + Assert.Equal("Writes stories", annotation.Agents[0].Description); + } + + #endregion +} diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj new file mode 100644 index 0000000000..9c1f22aca3 --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + $(TargetFrameworksCore) + + + + + + + + + + + + + + + diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs new file mode 100644 index 0000000000..28104aa67a --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIAggregatorHostedServiceTests.cs @@ -0,0 +1,298 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Aspire.Hosting.ApplicationModel; +using Microsoft.AspNetCore.Http; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DevUIAggregatorHostedServiceTests +{ + #region RewriteAgentIdInQueryString Tests + + /// + /// Verifies that RewriteAgentIdInQueryString returns empty string when query string has no value. + /// + [Fact] + public void RewriteAgentIdInQueryString_EmptyQueryString_ReturnsEmptyString() + { + // Arrange + var queryString = QueryString.Empty; + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Equal(string.Empty, result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString rewrites agent_id to the un-prefixed value. + /// + [Fact] + public void RewriteAgentIdInQueryString_WithPrefixedAgentId_RewritesToUnprefixed() + { + // Arrange + var queryString = new QueryString("?agent_id=writer-agent%2Fwriter"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.DoesNotContain("writer-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString preserves other query parameters. + /// + [Fact] + public void RewriteAgentIdInQueryString_WithOtherParams_PreservesOtherParams() + { + // Arrange + var queryString = new QueryString("?agent_id=writer-agent%2Fwriter&conversation_id=123&page=5"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.Contains("conversation_id=123", result); + Assert.Contains("page=5", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString works when agent_id is not the first parameter. + /// + [Fact] + public void RewriteAgentIdInQueryString_AgentIdNotFirst_StillRewrites() + { + // Arrange + var queryString = new QueryString("?page=1&agent_id=editor-agent%2Feditor&limit=10"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "editor"); + + // Assert + Assert.Contains("agent_id=editor", result); + Assert.DoesNotContain("editor-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString handles special characters in actual agent ID. + /// + [Fact] + public void RewriteAgentIdInQueryString_SpecialCharsInAgentId_UrlEncodesCorrectly() + { + // Arrange + var queryString = new QueryString("?agent_id=prefix%2Fmy-agent"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "my-agent"); + + // Assert + // The result should contain the agent_id with the value properly encoded if needed + Assert.Contains("agent_id=my-agent", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString handles an agent_id with no prefix. + /// + [Fact] + public void RewriteAgentIdInQueryString_NoPrefix_SetsDirectly() + { + // Arrange + var queryString = new QueryString("?agent_id=simple"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "new-value"); + + // Assert + Assert.Contains("agent_id=new-value", result); + Assert.DoesNotContain("simple", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString adds agent_id even if not originally present. + /// + [Fact] + public void RewriteAgentIdInQueryString_NoAgentId_AddsAgentId() + { + // Arrange + var queryString = new QueryString("?page=1&limit=10"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.Contains("agent_id=writer", result); + Assert.Contains("page=1", result); + Assert.Contains("limit=10", result); + } + + /// + /// Verifies that RewriteAgentIdInQueryString returns proper format starting with ?. + /// + [Fact] + public void RewriteAgentIdInQueryString_ValidQuery_ReturnsQueryStringFormat() + { + // Arrange + var queryString = new QueryString("?agent_id=test"); + + // Act + var result = DevUIAggregatorHostedService.RewriteAgentIdInQueryString(queryString, "writer"); + + // Assert + Assert.StartsWith("?", result); + } + + #endregion + + #region Backend Resolution Behavior Tests + + /// + /// Verifies that ResolveBackends returns empty dictionary when no annotations are present. + /// These tests verify the expected behavior of the aggregator via the DevUI resource annotations. + /// + [Fact] + public void DevUIResource_NoAnnotations_ResolveBackendsReturnsEmpty() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + + // Assert - no AgentServiceAnnotation means no backends + var annotations = devui.Resource.Annotations + .OfType() + .ToList(); + + Assert.Empty(annotations); + } + + /// + /// Verifies that WithAgentService adds proper annotations for backend resolution. + /// + [Fact] + public void WithAgentService_AddsAnnotation_ForBackendResolution() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + + // Act + devui.WithAgentService(agentService); + + // Assert + var annotation = devui.Resource.Annotations + .OfType() + .FirstOrDefault(); + + Assert.NotNull(annotation); + Assert.Equal("writer-agent", annotation.AgentService.Name); + } + + /// + /// Verifies that custom EntityIdPrefix is properly stored in the annotation. + /// + [Fact] + public void WithAgentService_CustomPrefix_StoresInAnnotation() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var agentService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + + // Act + devui.WithAgentService(agentService, entityIdPrefix: "custom-writer"); + + // Assert + var annotation = devui.Resource.Annotations + .OfType() + .First(); + + Assert.Equal("custom-writer", annotation.EntityIdPrefix); + } + + /// + /// Verifies that multiple agent services create multiple annotations for backend resolution. + /// + [Fact] + public void WithAgentService_MultipleServices_CreatesMultipleAnnotations() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var devui = builder.AddDevUI("devui"); + var writerService = CreateMockAgentServiceBuilder(builder, "writer-agent"); + var editorService = CreateMockAgentServiceBuilder(builder, "editor-agent"); + + // Act + devui.WithAgentService(writerService); + devui.WithAgentService(editorService); + + // Assert + var annotations = devui.Resource.Annotations + .OfType() + .ToList(); + + Assert.Equal(2, annotations.Count); + Assert.Contains(annotations, a => a.AgentService.Name == "writer-agent"); + Assert.Contains(annotations, a => a.AgentService.Name == "editor-agent"); + } + + #endregion + + #region Entity ID Parsing Tests + + /// + /// Verifies the expected format for prefixed entity IDs in the aggregator. + /// + [Theory] + [InlineData("writer-agent/writer", "writer-agent", "writer")] + [InlineData("editor-agent/editor", "editor-agent", "editor")] + [InlineData("custom/my-agent", "custom", "my-agent")] + [InlineData("prefix/sub/path", "prefix", "sub/path")] + public void PrefixedEntityId_Format_ExtractsCorrectly(string prefixedId, string expectedPrefix, string expectedRest) + { + // This test documents the expected format for prefixed entity IDs + // The aggregator uses "prefix/entityId" format where: + // - prefix is typically the resource name or custom prefix + // - entityId is the original entity identifier from the backend + + var slashIndex = prefixedId.IndexOf('/'); + var prefix = prefixedId[..slashIndex]; + var rest = prefixedId[(slashIndex + 1)..]; + + Assert.Equal(expectedPrefix, prefix); + Assert.Equal(expectedRest, rest); + } + + #endregion + + #region Helper Methods + + /// + /// Creates a mock agent service builder for testing. + /// Uses a minimal resource implementation that satisfies IResourceWithEndpoints. + /// + private static IResourceBuilder CreateMockAgentServiceBuilder( + IDistributedApplicationBuilder appBuilder, + string name) + { + // Create a mock resource that implements IResourceWithEndpoints + var mockResource = new Moq.Mock(); + mockResource.Setup(r => r.Name).Returns(name); + mockResource.Setup(r => r.Annotations).Returns(new ResourceAnnotationCollection()); + + var mockBuilder = new Moq.Mock>(); + mockBuilder.Setup(b => b.Resource).Returns(mockResource.Object); + mockBuilder.Setup(b => b.ApplicationBuilder).Returns(appBuilder); + + return mockBuilder.Object; + } + + #endregion +} diff --git a/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs new file mode 100644 index 0000000000..71409d21b0 --- /dev/null +++ b/dotnet/tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/DevUIResourceTests.cs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Net.Sockets; +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.AgentFramework.DevUI.UnitTests; + +/// +/// Unit tests for the class. +/// +public class DevUIResourceTests +{ + #region Constructor Tests + + /// + /// Verifies that the resource name is correctly set. + /// + [Fact] + public void Constructor_WithName_SetsName() + { + // Arrange & Act + var resource = new DevUIResource("test-devui"); + + // Assert + Assert.Equal("test-devui", resource.Name); + } + + /// + /// Verifies that the resource implements IResourceWithEndpoints. + /// + [Fact] + public void Resource_ImplementsIResourceWithEndpoints() + { + // Arrange & Act + var resource = new DevUIResource("test-devui"); + + // Assert + Assert.IsAssignableFrom(resource); + } + + /// + /// Verifies that the resource implements IResourceWithWaitSupport. + /// + [Fact] + public void Resource_ImplementsIResourceWithWaitSupport() + { + // Arrange & Act + var resource = new DevUIResource("test-devui"); + + // Assert + Assert.IsAssignableFrom(resource); + } + + #endregion + + #region Endpoint Annotation Tests + + /// + /// Verifies that the resource has an HTTP endpoint annotation when port is specified. + /// + [Fact] + public void Constructor_WithPort_AddsEndpointAnnotation() + { + // Arrange & Act + var resource = CreateResourceWithPort(8090); + + // Assert + var endpoint = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(endpoint); + Assert.Equal("http", endpoint.Name); + Assert.Equal(8090, endpoint.Port); + } + + /// + /// Verifies that the endpoint annotation has correct protocol type. + /// + [Fact] + public void EndpointAnnotation_HasTcpProtocol() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint = resource.Annotations.OfType().First(); + + // Assert + Assert.Equal(ProtocolType.Tcp, endpoint.Protocol); + } + + /// + /// Verifies that the endpoint annotation has HTTP URI scheme. + /// + [Fact] + public void EndpointAnnotation_HasHttpUriScheme() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint = resource.Annotations.OfType().First(); + + // Assert + Assert.Equal("http", endpoint.UriScheme); + } + + /// + /// Verifies that the endpoint is not proxied. + /// + [Fact] + public void EndpointAnnotation_IsNotProxied() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint = resource.Annotations.OfType().First(); + + // Assert + Assert.False(endpoint.IsProxied); + } + + /// + /// Verifies that the endpoint target host is localhost. + /// + [Fact] + public void EndpointAnnotation_TargetHostIsLocalhost() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint = resource.Annotations.OfType().First(); + + // Assert + Assert.Equal("localhost", endpoint.TargetHost); + } + + /// + /// Verifies that the endpoint has no fixed port when null is passed. + /// + [Fact] + public void Constructor_WithNullPort_EndpointHasNullPort() + { + // Arrange & Act + var resource = CreateResourceWithPort(null); + + // Assert + var endpoint = resource.Annotations.OfType().FirstOrDefault(); + Assert.NotNull(endpoint); + Assert.Null(endpoint.Port); + } + + #endregion + + #region PrimaryEndpoint Tests + + /// + /// Verifies that PrimaryEndpoint returns an endpoint reference. + /// + [Fact] + public void PrimaryEndpoint_ReturnsEndpointReference() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint = resource.PrimaryEndpoint; + + // Assert + Assert.NotNull(endpoint); + Assert.Same(resource, endpoint.Resource); + } + + /// + /// Verifies that PrimaryEndpoint returns the same instance on multiple calls. + /// + [Fact] + public void PrimaryEndpoint_MultipleCalls_ReturnsSameInstance() + { + // Arrange + var resource = CreateResourceWithPort(8080); + + // Act + var endpoint1 = resource.PrimaryEndpoint; + var endpoint2 = resource.PrimaryEndpoint; + + // Assert + Assert.Same(endpoint1, endpoint2); + } + + #endregion + + private static DevUIResource CreateResourceWithPort(int? port) => new("test-devui", port); +} From 3e54a689fc96d681a072fe7e7cfc445909dac74b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 20 Apr 2026 15:35:30 +0200 Subject: [PATCH 08/52] Python: Add search tool content for OpenAI responses (#5302) * Add OpenAI search tool content parsing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix typing * simplified oai image test * same for azure * skip az responses api test --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/_types.py | 54 ++- .../agent_framework_openai/_chat_client.py | 66 ++++ .../tests/openai/test_openai_chat_client.py | 308 ++++++++++++++++-- .../openai/test_openai_chat_client_azure.py | 26 +- 4 files changed, 417 insertions(+), 37 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 4b6c2f0401..f3ed9ad2d2 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -351,6 +351,8 @@ ContentType = Literal[ "image_generation_tool_result", "mcp_server_tool_call", "mcp_server_tool_result", + "search_tool_call", + "search_tool_result", "shell_tool_call", "shell_tool_result", "shell_command_output", @@ -864,6 +866,56 @@ class Content: raw_representation=raw_representation, ) + @classmethod + def from_search_tool_call( + cls: type[ContentT], + call_id: str, + *, + tool_name: str, + arguments: str | Mapping[str, Any] | None = None, + status: str | None = None, + annotations: Sequence[Annotation] | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + raw_representation: Any = None, + ) -> ContentT: + """Create search tool call content.""" + return cls( + "search_tool_call", + call_id=call_id, + tool_name=tool_name, + arguments=arguments, + status=status, + annotations=annotations, + additional_properties=additional_properties, + raw_representation=raw_representation, + ) + + @classmethod + def from_search_tool_result( + cls: type[ContentT], + call_id: str, + *, + tool_name: str, + result: Any = None, + items: Sequence[Content] | None = None, + status: str | None = None, + annotations: Sequence[Annotation] | None = None, + additional_properties: MutableMapping[str, Any] | None = None, + raw_representation: Any = None, + ) -> ContentT: + """Create search tool result content.""" + return cls( + "search_tool_result", + call_id=call_id, + tool_name=tool_name, + result=result, + items=list(items) if items is not None else None, + status=status, + annotations=annotations, + additional_properties=additional_properties, + raw_representation=raw_representation, + ) + @classmethod def from_usage( cls: type[ContentT], @@ -1478,7 +1530,7 @@ class Content: return span.lower() == top_level_media_type.lower() def parse_arguments(self) -> dict[str, Any | None] | None: - """Parse arguments from function_call or mcp_server_tool_call content. + """Parse arguments from function_call, mcp_server_tool_call, or search_tool_call content. If arguments cannot be parsed as JSON or the result is not a dict, they are returned as a dictionary with a single key "raw". diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 4aba988b39..5b7584dc6d 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -549,6 +549,7 @@ class RawOpenAIChatClient( # type: ignore[misc] chunk, options=validated_options, function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, ) else: async for chunk in await client.responses.create(stream=True, **run_options): @@ -556,6 +557,7 @@ class RawOpenAIChatClient( # type: ignore[misc] chunk, options=validated_options, function_call_ids=function_call_ids, + seen_reasoning_delta_item_ids=seen_reasoning_delta_item_ids, ) except Exception as ex: self._handle_request_error(ex) @@ -1587,6 +1589,54 @@ class RawOpenAIChatClient( # type: ignore[misc] """Join shell commands into a single executable command string.""" return "\n".join(command for command in commands if command).strip() + @staticmethod + def _serialize_provider_payload(value: Any) -> Any: + """Convert OpenAI SDK objects into JSON-serializable Python values.""" + if isinstance(value, BaseModel): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, Mapping): + return {str(key): RawOpenAIChatClient._serialize_provider_payload(item) for key, item in value.items()} # type: ignore[reportUnknownVariableType] + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [RawOpenAIChatClient._serialize_provider_payload(item) for item in value] # type: ignore[reportUnknownVariableType] + return value + + @staticmethod + def _get_search_tool_name(item_type: str) -> str: + """Map OpenAI search output item types to unified content tool names.""" + return "web_search" if item_type == "web_search_call" else "file_search" + + def _parse_search_tool_call_content(self, item: Any) -> Content: + """Create unified search tool call content from an OpenAI search output item.""" + item_type = getattr(item, "type", "") + call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or "" + if item_type == "web_search_call": + arguments = self._serialize_provider_payload(getattr(item, "action", None)) + else: + arguments = {"queries": list(getattr(item, "queries", []) or [])} + return Content.from_search_tool_call( + call_id=call_id, + tool_name=self._get_search_tool_name(item_type), + arguments=arguments, + status=getattr(item, "status", None), + raw_representation=item, + ) + + def _parse_search_tool_result_content(self, item: Any) -> Content: + """Create unified search tool result content from an OpenAI search output item.""" + item_type = getattr(item, "type", "") + call_id = getattr(item, "id", None) or getattr(item, "call_id", None) or "" + if item_type == "web_search_call": + result = {"action": self._serialize_provider_payload(getattr(item, "action", None))} + else: + result = {"results": self._serialize_provider_payload(getattr(item, "results", None))} + return Content.from_search_tool_result( + call_id=call_id, + tool_name=self._get_search_tool_name(item_type), + result=result, + status=getattr(item, "status", None), + raw_representation=item, + ) + # region Parse methods def _parse_response_from_openai( self, @@ -1788,6 +1838,9 @@ class RawOpenAIChatClient( # type: ignore[misc] raw_representation=item, ) ) + case "web_search_call" | "file_search_call": + contents.append(self._parse_search_tool_call_content(item)) + contents.append(self._parse_search_tool_result_content(item)) case "mcp_approval_request": # ResponseOutputMcpApprovalRequest contents.append( Content.from_function_approval_request( @@ -2377,8 +2430,19 @@ class RawOpenAIChatClient( # type: ignore[misc] additional_properties=additional_properties_empty or None, ) ) + case "web_search_call" | "file_search_call": + contents.append(self._parse_search_tool_call_content(event_item)) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) + case ( + "response.web_search_call.in_progress" + | "response.web_search_call.searching" + | "response.web_search_call.completed" + | "response.file_search_call.in_progress" + | "response.file_search_call.searching" + | "response.file_search_call.completed" + ): + pass case "response.function_call_arguments.delta": call_id, name = function_call_ids.get(event.output_index, (None, None)) if call_id and name: @@ -2514,6 +2578,8 @@ class RawOpenAIChatClient( # type: ignore[misc] raw_representation=done_item, ) ) + elif getattr(done_item, "type", None) in ("web_search_call", "file_search_call"): + contents.append(self._parse_search_tool_result_content(done_item)) case _: logger.debug("Unparsed event of type: %s: %s", event.type, event) diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 4472a218bc..8c956a6339 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -7,7 +7,7 @@ import os from datetime import datetime, timezone from pathlib import Path from typing import Annotated, Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ( @@ -71,6 +71,35 @@ class OutputStruct(BaseModel): weather: str | None = None +class _FakeAsyncEventStream: + def __init__(self, events: list[object]) -> None: + self._events = events + self._iterator = iter(()) + + def __aiter__(self) -> "_FakeAsyncEventStream": + self._iterator = iter(self._events) + return self + + async def __anext__(self) -> object: + try: + return next(self._iterator) + except StopIteration as exc: + raise StopAsyncIteration from exc + + +class _FakeAsyncEventStreamContext(_FakeAsyncEventStream): + async def __aenter__(self) -> "_FakeAsyncEventStreamContext": + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object | None, + ) -> None: + return None + + async def create_vector_store( client: OpenAIChatClient, ) -> tuple[str, Content]: @@ -1250,6 +1279,91 @@ def test_response_content_creation_with_function_call() -> None: assert function_call.arguments == '{"location": "Seattle"}' +def test_parse_response_from_openai_with_web_search_call() -> None: + """Test _parse_response_from_openai with web search output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp-web" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + mock_search_item = MagicMock() + mock_search_item.type = "web_search_call" + mock_search_item.id = "ws_123" + mock_search_item.status = "completed" + mock_search_item.action = { + "type": "search", + "query": "current weather in Seattle", + "queries": ["current weather in Seattle"], + "sources": [{"title": "Weather", "url": "https://weather.example"}], + } + + mock_response.output = [mock_search_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 2 + call_content, result_content = response.messages[0].contents + assert call_content.type == "search_tool_call" + assert call_content.call_id == "ws_123" + assert call_content.tool_name == "web_search" + assert call_content.status == "completed" + assert call_content.arguments == mock_search_item.action + assert result_content.type == "search_tool_result" + assert result_content.call_id == "ws_123" + assert result_content.tool_name == "web_search" + assert result_content.status == "completed" + assert result_content.result == {"action": mock_search_item.action} + + +def test_parse_response_from_openai_with_file_search_call() -> None: + """Test _parse_response_from_openai with file search output.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + + mock_response = MagicMock() + mock_response.output_parsed = None + mock_response.metadata = {} + mock_response.usage = None + mock_response.id = "resp-file" + mock_response.model = "test-model" + mock_response.created_at = 1000000000 + + mock_search_item = MagicMock() + mock_search_item.type = "file_search_call" + mock_search_item.id = "fs_123" + mock_search_item.status = "completed" + mock_search_item.queries = ["weather history"] + mock_search_item.results = [ + { + "file_id": "file_1", + "filename": "weather.txt", + "score": 0.9, + "text": "Seattle was cloudy.", + } + ] + + mock_response.output = [mock_search_item] + + response = client._parse_response_from_openai(mock_response, options={}) # type: ignore + + assert len(response.messages[0].contents) == 2 + call_content, result_content = response.messages[0].contents + assert call_content.type == "search_tool_call" + assert call_content.call_id == "fs_123" + assert call_content.tool_name == "file_search" + assert call_content.status == "completed" + assert call_content.arguments == {"queries": ["weather history"]} + assert result_content.type == "search_tool_result" + assert result_content.call_id == "fs_123" + assert result_content.tool_name == "file_search" + assert result_content.status == "completed" + assert result_content.result == {"results": mock_search_item.results} + + def test_prepare_content_for_opentool_approval_response() -> None: """Test _prepare_content_for_openai with function approval response content.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -1394,6 +1508,86 @@ def test_parse_response_from_openai_with_mcp_server_tool_result() -> None: assert result_content.output is not None +def test_parse_chunk_from_openai_with_web_search_call_added() -> None: + """Test that response.output_item.added for web_search_call emits search tool call content.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_event.output_index = 0 + + mock_item = MagicMock() + mock_item.type = "web_search_call" + mock_item.id = "ws_call_123" + mock_item.status = "in_progress" + mock_item.action = {"type": "search", "query": "weather in Seattle"} + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + content = update.contents[0] + assert content.type == "search_tool_call" + assert content.call_id == "ws_call_123" + assert content.tool_name == "web_search" + assert content.status == "in_progress" + assert content.arguments == {"type": "search", "query": "weather in Seattle"} + + +def test_parse_chunk_from_openai_with_file_search_call_done() -> None: + """Test that response.output_item.done for file_search_call emits search tool result content.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.output_item.done" + + mock_item = MagicMock() + mock_item.type = "file_search_call" + mock_item.id = "fs_call_123" + mock_item.status = "completed" + mock_item.results = [{"file_id": "file_1", "text": "Seattle was cloudy."}] + mock_event.item = mock_item + + update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids) + + assert len(update.contents) == 1 + content = update.contents[0] + assert content.type == "search_tool_result" + assert content.call_id == "fs_call_123" + assert content.tool_name == "file_search" + assert content.status == "completed" + assert content.result == {"results": [{"file_id": "file_1", "text": "Seattle was cloudy."}]} + + +@pytest.mark.parametrize( + "event_type", + [ + "response.web_search_call.in_progress", + "response.web_search_call.searching", + "response.web_search_call.completed", + "response.file_search_call.in_progress", + "response.file_search_call.searching", + "response.file_search_call.completed", + ], +) +def test_parse_chunk_from_openai_ignores_search_progress_events(event_type: str) -> None: + """Search progress events should be explicitly ignored instead of logged as unparsed.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options = ChatOptions() + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = event_type + + update = client._parse_chunk_from_openai(mock_event, options=chat_options, function_call_ids=function_call_ids) + + assert update.contents == [] + + def test_parse_chunk_from_openai_with_mcp_call_added_defers_result() -> None: """Test that response.output_item.added for mcp_call emits only the call, not the result. @@ -2716,6 +2910,48 @@ async def test_get_response_streaming_with_response_format() -> None: await run_streaming() +async def test_inner_get_response_streaming_with_response_format_tracks_reasoning_delta_ids() -> None: + """The responses.stream path should suppress reasoning done events after deltas.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + messages = [Message(role="user", contents=["Test streaming with format"])] + item_id = "reasoning_stream" + events = [ + ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=1, + delta="Hello ", + ), + ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=2, + text="Hello ", + ), + ] + + with ( + patch.object( + client, + "_prepare_request", + new=AsyncMock(return_value=(client.client, {"text_format": OutputStruct}, {})), + ), + patch.object(client.client.responses, "stream", return_value=_FakeAsyncEventStreamContext(events)), + patch.object(client, "_get_metadata_from_response", return_value={}), + ): + stream = client._inner_get_response(messages=messages, options={}, stream=True) + updates = [update async for update in stream] + + reasoning_chunks = [ + content.text for update in updates for content in update.contents if content.type == "text_reasoning" + ] + assert reasoning_chunks == ["Hello "] + + def test_prepare_content_for_openai_image_content() -> None: """Test _prepare_content_for_openai with image content variations.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -3153,6 +3389,44 @@ def test_streaming_reasoning_deltas_then_done_no_duplication() -> None: assert "".join(c.text for c in all_contents) == "Hello world" +async def test_inner_get_response_streaming_create_tracks_reasoning_delta_ids() -> None: + """The responses.create(stream=True) path should suppress reasoning done events after deltas.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + messages = [Message(role="user", contents=["Test streaming"])] + item_id = "reasoning_create" + events = [ + ResponseReasoningTextDeltaEvent( + type="response.reasoning_text.delta", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=1, + delta="Hello ", + ), + ResponseReasoningTextDoneEvent( + type="response.reasoning_text.done", + content_index=0, + item_id=item_id, + output_index=0, + sequence_number=2, + text="Hello ", + ), + ] + + with ( + patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))), + patch.object(client.client.responses, "create", new=AsyncMock(return_value=_FakeAsyncEventStream(events))), + patch.object(client, "_get_metadata_from_response", return_value={}), + ): + stream = client._inner_get_response(messages=messages, options={}, stream=True) + updates = [update async for update in stream] + + reasoning_chunks = [ + content.text for update in updates for content in update.contents if content.type == "text_reasoning" + ] + assert reasoning_chunks == ["Hello "] + + def test_streaming_reasoning_events_preserve_metadata() -> None: """Test that reasoning events preserve metadata like regular text events.""" client = OpenAIChatClient(model="test-model", api_key="test-key") @@ -3890,26 +4164,22 @@ async def test_integration_tool_rich_content_image() -> None: client = OpenAIChatClient() client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - messages = [ - Message( - role="user", - contents=["Call the get_test_image tool and describe what you see."], - ) - ] - options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + messages = [ + Message( + role="user", + contents=["Call the get_test_image tool and describe what you see."], + ) + ] + options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} - if streaming: - response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - else: - response = await client.get_response(messages=messages, options=options) + response = await client.get_response(messages=messages, stream=True, options=options).get_final_response() - assert response is not None - assert isinstance(response, ChatResponse) - assert response.text is not None - assert len(response.text) > 0 - # sample_image.jpg contains a photo of a house; the model should mention it. - assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" + assert response is not None + assert isinstance(response, ChatResponse) + assert response.text is not None + assert len(response.text) > 0 + # sample_image.jpg contains a photo of a house; the model should mention it. + assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" @pytest.mark.flaky diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index 4bec80f6b7..b16fbd0f7f 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -486,6 +486,7 @@ async def test_integration_client_agent_existing_session() -> None: @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled @_with_azure_openai_debug() +@pytest.mark.skip(reason="Azure OpenAI is flaky when handling image content as function result. Needs investigation.") async def test_azure_openai_chat_client_tool_rich_content_image() -> None: image_path = Path(__file__).parent.parent / "assets" / "sample_image.jpg" image_bytes = image_path.read_bytes() @@ -499,21 +500,12 @@ async def test_azure_openai_chat_client_tool_rich_content_image() -> None: client = OpenAIChatClient(credential=credential) client.function_invocation_configuration["max_iterations"] = 2 - for streaming in [False, True]: - messages = [Message(role="user", contents=["Call the get_test_image tool and describe what you see."])] - options: dict[str, Any] = {"tools": [get_test_image], "tool_choice": "auto"} + response = await client.get_response( + messages=[Message(role="user", contents=["Call the get_test_image tool and describe what you see."])], + stream=True, + options={"tools": [get_test_image], "tool_choice": "auto"}, + ).get_final_response() - if streaming: - response = await client.get_response( - messages=messages, - stream=True, - options=options, - ).get_final_response() - else: - response = await client.get_response(messages=messages, options=options) - - assert isinstance(response, ChatResponse) - assert response.text is not None - assert "house" in response.text.lower(), ( - f"Model did not describe the house image. Response: {response.text}" - ) + assert isinstance(response, ChatResponse) + assert response.text is not None + assert "house" in response.text.lower(), f"Model did not describe the house image. Response: {response.text}" From 04aaf0c1fe6023a579a334f9d2afe5b79ca497f0 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:56:01 +0900 Subject: [PATCH 09/52] Python: Add support for Foundry Toolboxes (#5346) * Add support for the Foundry Toolbox in MAF Introduces a Foundry Toolbox integration: FoundryChatClient gains a get_toolbox() helper plus select_toolbox_tools(), normalize_tools in the core package flattens tool-collection wrappers (ToolboxVersionObject and generic iterables, while leaving Pydantic BaseModel instances alone), and the new agent_framework.foundry namespace re-exports the toolbox helpers. Ships with unit tests, a sample, and a design doc. azure-ai-projects is pinned to the public >=2.0.0,<3.0 range and the lockfile resolves from public PyPI. The toolbox test module skips when Toolbox* types are unavailable so CI stays green until the public 2.1.0 SDK lands. OMC tooling directories (.omc/, .omx/) are gitignored. * Update to latest azure ai projects package * Improve sample * Rename ADR to 0025 * Update ADR * Apply suggestion from @alliscode Co-authored-by: Ben Thomas * Improve samples * Update test --------- Co-authored-by: Ben Thomas --- .gitignore | 2 + .../decisions/0025-foundry-toolbox-support.md | 454 ++++++++++++++++++ .../core/agent_framework/_feature_stage.py | 1 + .../packages/core/agent_framework/_tools.py | 28 ++ .../core/agent_framework/foundry/__init__.py | 4 + .../core/agent_framework/foundry/__init__.pyi | 8 + python/packages/core/tests/core/test_tools.py | 157 ++++++ python/packages/foundry/README.md | 63 +++ .../agent_framework_foundry/__init__.py | 5 + .../foundry/agent_framework_foundry/_agent.py | 16 + .../agent_framework_foundry/_chat_client.py | 52 +- .../foundry/agent_framework_foundry/_tools.py | 166 +++++++ python/packages/foundry/pyproject.toml | 2 +- .../tests/foundry/test_foundry_chat_client.py | 77 +++ python/packages/foundry/tests/test_toolbox.py | 435 +++++++++++++++++ .../02-agents/context_providers/README.md | 7 + .../foundry_toolbox_context_provider.py | 207 ++++++++ .../02-agents/providers/foundry/README.md | 2 + .../foundry_chat_client_with_toolbox.py | 174 +++++++ .../foundry_chat_client_with_toolbox_mcp.py | 118 +++++ python/uv.lock | 8 +- 21 files changed, 1980 insertions(+), 6 deletions(-) create mode 100644 docs/decisions/0025-foundry-toolbox-support.md create mode 100644 python/packages/foundry/agent_framework_foundry/_tools.py create mode 100644 python/packages/foundry/tests/test_toolbox.py create mode 100644 python/samples/02-agents/context_providers/foundry_toolbox_context_provider.py create mode 100644 python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py create mode 100644 python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_mcp.py diff --git a/.gitignore b/.gitignore index 2267fb20c4..c846efea7b 100644 --- a/.gitignore +++ b/.gitignore @@ -203,6 +203,8 @@ temp*/ # AI .claude/ +.omc/ +.omx/ WARP.md **/memory-bank/ **/projectBrief.md diff --git a/docs/decisions/0025-foundry-toolbox-support.md b/docs/decisions/0025-foundry-toolbox-support.md new file mode 100644 index 0000000000..a68b98b3bf --- /dev/null +++ b/docs/decisions/0025-foundry-toolbox-support.md @@ -0,0 +1,454 @@ +--- +status: proposed +contact: evmattso +date: 2026-04-10 +deciders: evmattso +--- + +# Foundry Toolbox Support in FoundryChatClient + +## What is the goal of this feature? + +Enable Agent Framework users to consume Foundry **toolboxes** — named, versioned bundles of tool definitions stored server-side in an Azure AI Foundry project — directly from `FoundryChatClient`, without dropping to the raw `azure-ai-projects` SDK. + +A user who has configured a toolbox in the Foundry portal (or via the raw SDK) should be able to load it into an agent with a single call: + +```python +toolbox = await client.get_toolbox("research_tools") +agent = Agent(client=client, instructions="...", tools=toolbox) +``` + +**Success metric:** an agent can consume a toolbox with no manual handling of version-resolution logic on the user's side. + +## What is the problem being solved? + +`azure-ai-projects==2.1.0a20260409002` ships a new `BetaToolboxesOperations` surface, reachable as `AIProjectClient.beta.toolboxes` on the raw SDK client (and therefore as `FoundryChatClient.project_client.beta.toolboxes` through our wrapper), that lets teams: +- Group related hosted tools (code interpreter, file search, MCP, web search, etc.) under a named toolbox +- Version toolboxes immutably, so agents can pin to a specific configuration for production stability +- Share toolboxes across multiple agents in a project + +However, consuming a toolbox from the framework today requires: +1. Knowing the raw SDK accessor path (`client.project_client.beta.toolboxes`) +2. Making two calls for the common case — `.get(name)` to find the default version, then `.get_version(name, version)` to actually retrieve tools +3. Manually unpacking `toolbox.tools` before passing them to `Agent(tools=...)` + +None of this is hard, but it's the kind of boilerplate that should live in the client. Every other hosted tool in `FoundryChatClient` (code interpreter, file search, web search, image generation, MCP) already has a factory method (`get_code_interpreter_tool()`, etc.). Toolbox support should fit the same shape on the chat-client composition surface. + +## API Changes + +### One new method on the FoundryChatClient surface + +The public toolbox-consumption surface lands on: + +- `RawFoundryChatClient` (inherited by `FoundryChatClient`) in `_chat_client.py` + +The implementation delegates to shared helper functions in `_tools.py` so there is a single source of truth for the SDK calls. + +**Scope note:** `FoundryAgent` is intentionally not part of this design. `FoundryAgent` is the runtime surface for invoking an already-configured server-side Foundry agent; if that agent should use a toolbox, the toolbox/tools should already be configured on the Foundry side (UI or `azure-ai-projects` authoring flow) before MAF connects to it. + +**Scope note:** Authoring a server-side agent whose definition references a toolbox (via `PromptAgentDefinition(tools=toolbox.tools, ...)` + `client.agents.create_version(...)`) is deliberately outside MAF scope. That is an `azure-ai-projects` / service-resource authoring concern, not a future MAF feature. Users who need it should use the raw Azure SDK directly. + +```python +async def get_toolbox( + self, + name: str, + *, + version: str | None = None, +) -> ToolboxVersionObject: + """Fetch a Foundry toolbox by name. + + If ``version`` is ``None``, resolves the toolbox's current default version + (two requests). If ``version`` is specified, fetches that version directly + (single request). + + :param name: The name of the toolbox. + :param version: Optional immutable version identifier to pin to. + :return: A ``ToolboxVersionObject``. Pass its ``tools`` attribute to + ``Agent(tools=toolbox.tools)``. + :raises azure.core.exceptions.ResourceNotFoundError: If the toolbox or + version does not exist. + """ + +``` + +### Return types: raw SDK models, no custom wrappers + +Methods return the `azure.ai.projects.models` types directly: + +- `get_toolbox()` → `ToolboxVersionObject` (has `.name`, `.version`, `.tools`, `.id`, `.created_at`, `.description`, `.metadata`, `.policies`) + +No custom wrapper classes are defined. Returning the SDK types directly: +- Eliminates maintenance overhead of keeping a custom wrapper aligned with SDK changes +- Matches the existing convention — `get_code_interpreter_tool()` returns the raw `CodeInterpreterTool` SDK type +- Means any new fields the SDK adds to these types flow through automatically + +`Agent(..., tools=...)` will accept the fetched toolbox object directly by flattening to `toolbox.tools` internally. + +### Design decisions + +**Instance methods, not `@staticmethod` factories.** Existing `get_code_interpreter_tool()` / `get_mcp_tool()` / etc. are `@staticmethod` because they're pure factories with no network I/O. Toolbox fetching requires the project client, so these new methods must be instance methods. This is a deliberate departure from the existing-factory pattern, justified by the async-with-I/O nature of the operation. + +**Raw SDK type passthrough (no custom wrappers).** There is only one toolbox type in the Foundry SDK and maintaining a shadow wrapper would create alignment risk as the SDK evolves. The raw `ToolboxVersionObject` and `ToolboxObject` carry all the fields users need. Individual tools inside `toolbox.tools` are the same `azure.ai.projects.models.Tool` subclasses returned by other factory methods. + +**Two-request default-version path.** When `version=None`, implementation calls `.get(name)` to find `default_version`, then `.get_version(name, default_version)` for the tools. Caching the default-version mapping was considered and rejected — default versions can change server-side via `update(default_version=...)`, and a stale cache would silently give callers the wrong tools. Two requests at agent setup is acceptable. + +**No discovery/listing surface in MAF.** Discovery is intentionally left to the raw `azure-ai-projects` client. MAF does not currently expose project-resource listing surfaces for many other Foundry resources (deployments, vector stores, agents, etc.), so the toolbox design stays narrowly focused on explicit retrieval by name/version. + +**Shared helpers in `_tools.py`.** The SDK-call helper function (`fetch_toolbox`) lives in a shared module so the chat-client surface stays thin and the request logic remains centralized. + +**`tools=toolbox` convenience, not a new wrapper type.** Although `get_toolbox()` returns the raw `ToolboxVersionObject`, Agent Framework can still support `tools=toolbox` / `tools=[toolbox]` by flattening the toolbox's `.tools` internally. That matches existing SDK ergonomics where some higher-level objects can be placed directly in `tools=` and unpacked underneath, without introducing a public `FoundryToolbox` wrapper. + +**Errors pass through unchanged.** `ResourceNotFoundError`, `HttpResponseError`, etc. from the SDK propagate as-is. No framework-specific exception hierarchy. + +## E2E Code Samples + +### Primary sample + +New file: `samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py` + +```python +import asyncio + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + + +async def main() -> None: + client = FoundryChatClient(credential=AzureCliCredential()) + + toolbox = await client.get_toolbox("research_tools") + print(f"Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tools)") + + agent = Agent( + client=client, + instructions="You are a research assistant.", + tools=toolbox, + ) + + result = await agent.run("What are the latest developments in quantum error correction?") + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### Version pinning + +```python +toolbox = await client.get_toolbox("research_tools", version="v3") +``` + +### Combining multiple toolboxes + +```python +toolbox_a = await client.get_toolbox("research_tools") +toolbox_b = await client.get_toolbox("some_other_tools", version="v3") + +agent = Agent( + client=client, + instructions="...", + tools=[toolbox_a, toolbox_b], +) +``` + +### Combining toolbox tools with locally defined tools + +```python +toolbox = await client.get_toolbox("research_tools") + +def get_internal_metrics(metric_name: str) -> dict: + """Custom tool that reads from an internal dashboard.""" + ... + +agent = Agent( + client=client, + instructions="...", + tools=[get_internal_metrics, toolbox], +) +``` + +### Selecting only some tools from a toolbox + +Developers will not always want to pass the entire toolbox through unchanged. A +small helper in the Foundry package provides local post-fetch selection without +changing the raw return type of `get_toolbox()`. + +```python +from agent_framework.foundry import select_toolbox_tools + +toolbox = await client.get_toolbox("research_tools") + +selected_tools = select_toolbox_tools( + toolbox, + include_names=["githubmcp", "code_interpreter"], +) + +agent = Agent( + client=client, + instructions="Use only the selected toolbox tools.", + tools=selected_tools, +) +``` + +Supported filters: + +```python +from agent_framework.foundry import FoundryHostedToolType, select_toolbox_tools + +selected_tools = select_toolbox_tools( + toolbox, + include_types=["mcp", "code_interpreter"], # type: Collection[FoundryHostedToolType] + exclude_names=["internal_admin_tool"], +) +``` + +Helper signature: + +```python +type FoundryHostedToolType = Literal[ + "code_interpreter", + "file_search", + "image_generation", + "mcp", + "web_search", +] | str + +def select_toolbox_tools( + tools: ToolboxVersionObject | Sequence[Tool | dict[str, Any]], + *, + include_names: Collection[str] | None = None, + exclude_names: Collection[str] | None = None, + include_types: Collection[FoundryHostedToolType] | None = None, + exclude_types: Collection[FoundryHostedToolType] | None = None, + predicate: Callable[[Tool | dict[str, Any]], bool] | None = None, +) -> list[Tool | dict[str, Any]]: + ... +``` + +Normalized name precedence for `include_names` / `exclude_names`: + +1. MCP `server_label` +2. generic tool `name` +3. fallback tool `type` + +This keeps `get_toolbox()` as a thin fetch API and makes selection an explicit, +local post-processing step, while still allowing the ergonomic +`select_toolbox_tools(toolbox, ...)` call shape. + +## Native vs MCP consumption of a Foundry toolbox + +A Foundry toolbox can be consumed two ways. This design adds new implementation work only for the first: + +1. **Native consumption (in scope).** Tools execute inside Foundry's agent runtime. `get_toolbox()` returns the `ToolboxVersionObject` whose `.tools` attribute carries typed tool configs that the runtime interprets server-side. This design is specifically for `FoundryChatClient`-backed local agent composition. + +2. **MCP consumption (already supported through existing MCP abstractions).** A Foundry toolbox can also be exposed as an MCP server. In that case, use the existing `MCPStreamableHTTPTool(name=..., url=...)` — it already handles this path with any chat client (Foundry, OpenAI, Anthropic, etc.). No new Foundry-specific API is needed for MCP-exposed toolboxes in this design. + +### MCPStreamableHTTPTool example for a Foundry toolbox endpoint + +If Foundry gives you an MCP endpoint for the toolbox (for example from the +toolbox details UI / endpoint surface), the existing MCP client path is: + +```python +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.openai import OpenAIChatClient + +toolbox_mcp = MCPStreamableHTTPTool( + name="research_tools", + url="https://", +) + +agent = Agent( + client=OpenAIChatClient(), + instructions="You are a research assistant.", + tools=[toolbox_mcp], +) +``` + +This is a different integration shape than `get_toolbox(...).tools`: + +- `get_toolbox(...).tools` = **native Foundry hosted-tool configs** interpreted by the + Foundry runtime +- `MCPStreamableHTTPTool(name=..., url=...)` = **live MCP server connection** to a + toolbox endpoint + +The design in this spec adds first-class support only for the native hosted-tool +path. The MCP path is already served by the framework's existing MCP abstractions. + +These paths are not unified because they have fundamentally different execution models. Native toolbox tools are declarative configs the Foundry runtime executes; MCP consumption is a live wire protocol to a running server. + +**MCP authentication inside a toolbox** is handled server-side via `project_connection_id` on individual `MCPTool` entries (OAuth connection objects configured in the Foundry project). The client never holds bearer tokens. Consent flow handling (`CONSENT_REQUIRED` → user-visible consent URL) happens during `agent.run()`, not during toolbox fetching — see Non-goals. + +## Testing Strategy + +Unit tests in `packages/foundry/tests/test_toolbox.py` with mocked `project_client.beta.toolboxes`. A single opt-in live round-trip, `test_integration_get_toolbox_round_trip_against_real_project`, is marked `@pytest.mark.integration`; it is skipped by default and only runs when the required Foundry credentials are available. + +Coverage: + +- `get_toolbox(name, version="v3")` — explicit version, single request. Assert `.get` not called, `.get_version` awaited once, returns `ToolboxVersionObject`. +- `get_toolbox(name)` — default-version resolution. Assert `.get` then `.get_version` called in order with correct args. +- Error propagation — `ResourceNotFoundError` from `.get` propagates unchanged. +- Tool passthrough — heterogeneous tool list (`CodeInterpreterTool`, `MCPTool(project_connection_id=...)`) passes through unchanged. Asserts `project_connection_id` survives. +- Agent integration smoke — `tools=toolbox` / `tools=[toolbox]` flatten to the underlying toolbox tools. +- Multiple toolbox composition smoke — `tools=[toolbox_a, toolbox_b]` flattens into a single agent tool list. +- `get_toolbox_tool_name()` — selection-name precedence is MCP `server_label`, then `name`, then `type`. +- `select_toolbox_tools(toolbox, include_names=...)` — selects by normalized tool names directly from a fetched toolbox object. +- `select_toolbox_tools(toolbox, include_types=...)` — selects by tool types with `Literal`-guided IDE completion. +- `select_toolbox_tools(..., exclude_names=..., predicate=...)` — supports exclusion + custom predicates. + +Deliberately **not** covered: +- Runtime consent-flow handling for OAuth MCP tools (see Non-goals). +- Toolbox discovery/listing (`list_toolboxes`, `list_toolbox_versions`) — deliberately left to the raw Azure SDK. +- Full CRUD (`create_version`, `update`, `delete`) and server-side agent authoring — see Non-goals. + +Live Foundry API integration is exercised only through the opt-in `@pytest.mark.integration` round-trip noted above; it is not part of the default test run. + +## Framework dependency: `normalize_tools` flattening + +The core `normalize_tools` function in `packages/core/agent_framework/_tools.py` already supports flattening composite tool inputs. Toolbox support extends that behavior so a fetched `ToolboxVersionObject` is treated as a composite tool source and flattened to its `.tools`. + +That enables: + +- `tools=toolbox` +- `tools=[toolbox]` +- `tools=[local_tool, toolbox]` +- `tools=[toolbox_a, toolbox_b]` + +while still keeping `select_toolbox_tools(toolbox.tools, ...)` available for partial selection before the final agent construction step. + +## Telemetry + +Telemetry for toolbox support has two separate goals: + +1. **Observe toolbox API access** — `get_toolbox()` +2. **Observe toolbox usage during agent runs** — when users pass toolbox-derived tools into `Agent(..., tools=...)` + +### Request telemetry for toolbox API access + +When Agent Framework constructs the `AIProjectClient` internally for `FoundryChatClient`, it already sets: + +```python +user_agent=AGENT_FRAMEWORK_USER_AGENT +``` + +That means toolbox API requests made through: + +- `project_client.beta.toolboxes.get(...)` +- `project_client.beta.toolboxes.get_version(...)` + +carry the standard MAF user-agent marker and can be queried in backend request logs the same way as other Foundry SDK calls made through framework-owned clients. + +Important constraint: if the caller passes an already-constructed `project_client`, Agent Framework does **not** mutate it to inject the MAF user-agent. In that case, toolbox API request telemetry reflects whatever user-agent behavior that external client was configured with. + +### Runtime telemetry for toolbox usage on agent runs + +Tool-level telemetry already captures which hosted Foundry tools are available / invoked during agent execution. The remaining gap is **toolbox provenance**: once the user writes `tools=toolbox` (or otherwise flattens the toolbox into tool configs), the framework sees only raw tool configs and no longer knows which toolbox name/version supplied them. + +The design for closing the **client-side** observability gap is **internal provenance tracking**, not user-supplied metadata and not a new public wrapper type. + +#### Provenance model + +Note: this section is still under investigation. + +When `get_toolbox()` or `list_toolbox_versions()` returns a `ToolboxVersionObject`, Agent Framework will attach private provenance metadata to: + +- the returned toolbox object +- each tool inside `toolbox.tools` + +Recommended shape (private, internal-only): + +```python +tool._maf_toolbox_sources = [ + { + "id": toolbox.id, + "name": toolbox.name, + "version": toolbox.version, + } +] +``` + +Key properties of this approach: + +- **No new public API surface** — users still work with raw `ToolboxVersionObject` / `ToolboxObject` +- **No user burden** — callers do not need to stamp metadata manually +- **Provenance follows the tool objects** — works with: + - `tools=toolbox.tools` + - `tools=[toolbox_a.tools, toolbox_b.tools]` + - `tools=[*toolbox_a.tools, *toolbox_b.tools]` +- **Private attributes are not serialized** into the actual request payload sent to the model/service, so this metadata does not leak into the tool definition body + +This is intentionally preferred over introducing a new public `FoundryToolbox` wrapper purely for telemetry, and preferred over a separate global provenance registry. The provenance lives on the existing tool objects so list-copying and chat-option merging naturally preserve it. + +#### Span enrichment + +When Agent / chat telemetry computes span attributes for a run, it should inspect the final tool list and aggregate the private toolbox provenance from any tool objects that carry it. The aggregated values are then emitted as attributes on the existing run/chat spans. + +Suggested custom attributes: + +- `agent_framework.foundry.toolbox.ids` +- `agent_framework.foundry.toolbox.names` +- `agent_framework.foundry.toolbox.versions` +- or a single compact attribute such as `agent_framework.foundry.toolbox.sources=["research_tools@1","some_other_tools@3"]` + +The single compact `toolbox.sources` form is preferred for initial implementation because it is easy to query and easy to render from combined tool lists. + +#### Scope of telemetry changes + +This design does **not** require new spans. It enriches existing telemetry: + +- toolbox API access continues to rely on request logs + Azure SDK distributed tracing + MAF user-agent +- agent/chat execution spans gain toolbox provenance attributes when toolbox-derived tools are present + +Implementation-wise, this design most likely touches: + +- `packages/foundry/agent_framework_foundry/_tools.py` — to stamp provenance on fetched toolbox objects / tools +- `packages/core/agent_framework/observability.py` — to aggregate provenance into span attributes + +#### Important limitation: no server-side toolbox telemetry solution yet + +Private provenance attached to tool objects is only useful on the client side. It +does **not** go over the wire to the Foundry service because those private fields +are intentionally not serialized into the request payload. + +That means this design can support: + +- local OpenTelemetry / exporter spans emitted by Agent Framework +- local attribution of a run to one or more fetched toolboxes + +but it does **not** solve: + +- server-side request-log attribution of a model/tool run back to a toolbox +- backend/database queries that need the service itself to know "this tool came from toolbox X" + +At the moment, we do not have a satisfactory design for server-side toolbox +telemetry. The service would require additional structured information on the +request, and there is no accepted mechanism in this design yet for projecting +toolbox provenance into a server-visible field/header/metadata shape. + +So the telemetry story in this spec is explicitly limited to **client-side +toolbox telemetry**. Server-side toolbox attribution remains an open question and +requires either: + +- new service/API support, or +- a later framework design for emitting additional server-visible request metadata. + +#### Deliberate non-goals for telemetry + +- No requirement for users to pass explicit toolbox metadata in `default_options["metadata"]` or `run(..., options=...)` +- No new public `FoundryToolbox` wrapper type just to preserve attribution +- No attempted server-side attribution mechanism in this design (for example a custom request header or request metadata field) until there is a validated end-to-end contract for it + +## Non-goals / Future Work + +Explicitly out of scope for this design. Each is a separate design and PR when needed. + +1. **Create/update/delete toolboxes from code.** CRUD is rare in agent consumption flows. Users who need it drop to `client.project_client.beta.toolboxes.create_version(...)`, `.update(...)`, `.delete(...)` directly. + +2. **Server-side agent authoring from toolbox.** Creating a `PromptAgentDefinition(tools=toolbox.tools)` + `client.agents.create_version(...)` is a future feature covering agent authoring from code. The toolbox read API provides the building blocks; the authoring helpers are a separate design. + +3. **OAuth consent-flow runtime handling.** When a toolbox contains MCP tools with `project_connection_id` pointing to an OAuth connection, the runtime may return `CONSENT_REQUIRED` mid-run. This is a runtime concern separate from toolbox fetching. + +4. **Live integration tests.** This PR ships unit tests only. + +5. **Toolbox caching or refresh APIs.** Each `get_toolbox()` call hits the network. Users who want caching wrap the call themselves. diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 1bda62b5d3..761b7860a4 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum): EVALS = "EVALS" FILE_HISTORY = "FILE_HISTORY" SKILLS = "SKILLS" + TOOLBOXES = "TOOLBOXES" class ReleaseCandidateFeature(str, Enum): diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 75b21d9932..5f5e91b656 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -12,6 +12,7 @@ from collections.abc import ( AsyncIterable, Awaitable, Callable, + Iterable, Mapping, Sequence, ) @@ -859,6 +860,15 @@ def normalize_tools( Returns: A normalized list where callable inputs are converted to ``FunctionTool`` using :func:`tool`, and existing tool objects are passed through unchanged. + + Tool-collection wrappers are flattened in two forms: + + - non-tool, non-callable iterables + - mapping-like objects that expose a ``.tools`` collection (for example + ``ToolboxVersionObject`` from azure-ai-projects) + + This lets callers write ``tools=[toolbox, my_func]`` and have the + toolbox's contents spread in alongside individual tools. """ if not tools: return [] @@ -883,6 +893,24 @@ def normalize_tools( if callable(tool_item): # type: ignore[reportUnknownArgumentType] normalized.append(tool(tool_item)) continue + # Mapping-like tool collections (for example ToolboxVersionObject) are + # not flattened by the generic Iterable branch below because they are + # also Mapping instances. If they expose a ``tools`` collection, spread + # that collection into the normalized list. + collection_tools = getattr(tool_item, "tools", None) # type: ignore[reportUnknownArgumentType] + if isinstance(collection_tools, Iterable) and not isinstance( + collection_tools, (str, bytes, bytearray, Mapping) + ): + normalized.extend(normalize_tools(list(collection_tools))) # type: ignore[reportUnknownArgumentType] + continue + # Tool-collection wrapper (e.g. FoundryToolbox): a non-tool, non-callable + # iterable. Flatten its contents so ``tools=[toolbox, my_func]`` works. + # Strings, mappings, and Pydantic BaseModel are excluded — BaseModel + # instances iterate over (field, value) tuples, not tools, so they + # should pass through as leaf tool specs (handled below). + if isinstance(tool_item, Iterable) and not isinstance(tool_item, (str, bytes, bytearray, Mapping, BaseModel)): + normalized.extend(normalize_tools(list(tool_item))) # type: ignore[reportUnknownArgumentType] + continue normalized.append(tool_item) # type: ignore[reportUnknownArgumentType] return normalized diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index b1d2b88450..c1e47cd6b8 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -20,6 +20,7 @@ _IMPORTS: dict[str, tuple[str, str]] = { "FoundryEmbeddingOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryEmbeddingSettings": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryEvals": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryHostedToolType": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryMemoryProvider": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryLocalChatOptions": ("agent_framework_foundry_local", "agent-framework-foundry-local"), "FoundryLocalClient": ("agent_framework_foundry_local", "agent-framework-foundry-local"), @@ -31,6 +32,9 @@ _IMPORTS: dict[str, tuple[str, str]] = { "RawFoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_foundry_target": ("agent_framework_foundry", "agent-framework-foundry"), "evaluate_traces": ("agent_framework_foundry", "agent-framework-foundry"), + "get_toolbox_tool_name": ("agent_framework_foundry", "agent-framework-foundry"), + "get_toolbox_tool_type": ("agent_framework_foundry", "agent-framework-foundry"), + "select_toolbox_tools": ("agent_framework_foundry", "agent-framework-foundry"), } diff --git a/python/packages/core/agent_framework/foundry/__init__.pyi b/python/packages/core/agent_framework/foundry/__init__.pyi index 47eb92b3af..87cc7a3bda 100644 --- a/python/packages/core/agent_framework/foundry/__init__.pyi +++ b/python/packages/core/agent_framework/foundry/__init__.pyi @@ -12,6 +12,7 @@ from agent_framework_foundry import ( FoundryEmbeddingOptions, FoundryEmbeddingSettings, FoundryEvals, + FoundryHostedToolType, FoundryMemoryProvider, RawFoundryAgent, RawFoundryAgentChatClient, @@ -19,6 +20,9 @@ from agent_framework_foundry import ( RawFoundryEmbeddingClient, evaluate_foundry_target, evaluate_traces, + get_toolbox_tool_name, + get_toolbox_tool_type, + select_toolbox_tools, ) from agent_framework_foundry_local import ( FoundryLocalChatOptions, @@ -35,6 +39,7 @@ __all__ = [ "FoundryEmbeddingOptions", "FoundryEmbeddingSettings", "FoundryEvals", + "FoundryHostedToolType", "FoundryLocalChatOptions", "FoundryLocalClient", "FoundryLocalSettings", @@ -46,4 +51,7 @@ __all__ = [ "RawFoundryEmbeddingClient", "evaluate_foundry_target", "evaluate_traces", + "get_toolbox_tool_name", + "get_toolbox_tool_type", + "select_toolbox_tools", ] diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 91ba663d84..6fa7172295 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -1144,3 +1144,160 @@ def test_parse_annotation_with_annotated_and_literal(): # endregion + + +# region normalize_tools flattening of tool-collection wrappers + + +def _make_flatten_function_tool(name: str) -> FunctionTool: + """Build a FunctionTool for flattening tests.""" + + @tool(name=name, description=f"{name} tool") + def _impl(x: int) -> int: + return x + + return _impl # type: ignore[return-value] + + +def test_normalize_tools_flattens_tool_collection_wrapper() -> None: + """A non-tool, non-callable iterable inside the tools list is flattened.""" + from agent_framework._tools import normalize_tools + + inner_a = _make_flatten_function_tool("inner_a") + inner_b = _make_flatten_function_tool("inner_b") + + class ToolBundle: + """Minimal stand-in for a tool-collection wrapper like FoundryToolbox.""" + + def __init__(self, tools: list[FunctionTool]) -> None: + self._tools = tools + + def __iter__(self): + return iter(self._tools) + + bundle = ToolBundle([inner_a, inner_b]) + + normalized = normalize_tools([bundle]) + + assert len(normalized) == 2 + assert normalized[0] is inner_a + assert normalized[1] is inner_b + + +def test_normalize_tools_combines_bundle_with_individual_tools() -> None: + """The canonical ``tools=[bundle, my_func]`` call site spreads bundle + individual.""" + from agent_framework._tools import normalize_tools + + bundled = _make_flatten_function_tool("bundled") + standalone = _make_flatten_function_tool("standalone") + + class ToolBundle: + def __init__(self, tools: list[FunctionTool]) -> None: + self._tools = tools + + def __iter__(self): + return iter(self._tools) + + normalized = normalize_tools([ToolBundle([bundled]), standalone]) + + assert len(normalized) == 2 + assert normalized[0] is bundled + assert normalized[1] is standalone + + +def test_normalize_tools_flattens_nested_bundles() -> None: + """Bundles inside bundles are flattened recursively via the recursive call.""" + from agent_framework._tools import normalize_tools + + inner = _make_flatten_function_tool("deep") + + class ToolBundle: + def __init__(self, tools: list[Any]) -> None: + self._tools = tools + + def __iter__(self): + return iter(self._tools) + + nested = ToolBundle([ToolBundle([inner])]) + + normalized = normalize_tools([nested]) + + assert len(normalized) == 1 + assert normalized[0] is inner + + +def test_normalize_tools_bundle_only_form() -> None: + """Passing a bundle directly (no outer list) also flattens its contents. + + ``tools=bundle`` — the outer wrap-in-list happens in the non-Sequence + branch, then the flattening logic kicks in on the inner pass. + """ + from agent_framework._tools import normalize_tools + + a = _make_flatten_function_tool("a") + b = _make_flatten_function_tool("b") + + class ToolBundle: + def __init__(self, tools: list[FunctionTool]) -> None: + self._tools = tools + + def __iter__(self): + return iter(self._tools) + + normalized = normalize_tools(ToolBundle([a, b])) # type: ignore[arg-type] + + assert len(normalized) == 2 + assert normalized[0] is a + assert normalized[1] is b + + +def test_normalize_tools_does_not_flatten_known_tool_types() -> None: + """FunctionTool / dict / callable are detected before the flatten branch.""" + from agent_framework._tools import normalize_tools + + func_tool = _make_flatten_function_tool("ft") + dict_tool: dict[str, Any] = {"type": "code_interpreter", "container": {"type": "auto"}} + + def plain_callable(x: int) -> int: + return x + + normalized = normalize_tools([func_tool, dict_tool, plain_callable]) + + assert len(normalized) == 3 + assert normalized[0] is func_tool + assert normalized[1] is dict_tool + # plain_callable was wrapped in a FunctionTool via the @tool helper + assert isinstance(normalized[2], FunctionTool) + + +def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None: + """Mapping-like toolbox objects with ``.tools`` should still flatten.""" + from collections.abc import Mapping as MappingABC + + from agent_framework._tools import normalize_tools + + bundled = _make_flatten_function_tool("bundled") + standalone = _make_flatten_function_tool("standalone") + + class ToolBundleMapping(MappingABC[str, Any]): + def __init__(self, tools: list[FunctionTool]) -> None: + self.tools = tools + self._data = {"name": "research_tools", "version": "v1", "tools": tools} + + def __getitem__(self, key: str) -> Any: + return self._data[key] + + def __iter__(self): + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + normalized = normalize_tools([ToolBundleMapping([bundled]), standalone]) + + assert len(normalized) == 2 + assert normalized[0] is bundled + assert normalized[1] is standalone + + +# endregion diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index e22fb523a5..26f9a6e309 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -1,3 +1,66 @@ # Agent Framework Foundry This package contains the Microsoft Foundry integrations for Microsoft Agent Framework, including Foundry chat clients, preconfigured Foundry agents, Foundry embedding clients, and Foundry memory providers. + +## Toolboxes + +A *toolbox* is a named, versioned bundle of hosted tool configurations — code interpreter, file search, image generation, MCP, web search, and so on — stored inside a Microsoft Foundry project. Toolboxes let you manage tool configuration once and reuse it across agents. + +### Authoring a toolbox + +Toolboxes can be authored two ways: + +- **Foundry portal** — create and version toolboxes through the UI without touching code. +- **Programmatically** — use the [`azure-ai-projects`](https://pypi.org/project/azure-ai-projects/) SDK to create, update, and version toolboxes from Python. + +> Toolbox authoring APIs (`ToolboxVersionObject`, `ToolboxObject`, `project_client.beta.toolboxes.*`) require `azure-ai-projects>=2.1.0`. Earlier versions can only consume toolboxes that already exist. + +### Using toolboxes with `FoundryAgent` + +For hosted `FoundryAgent`, the toolbox must already be attached to the agent in the Microsoft Foundry project. Once attached, the agent invokes its toolbox tools transparently — no client-side wiring required — and you interact with the agent the same way you would with any other tool-equipped Foundry agent. + +### Using toolboxes with `FoundryChatClient` + +There are two patterns for wiring a toolbox into a `FoundryChatClient`-backed agent. + +**1. Fetch, optionally filter, and pass the tools directly** + +Load the toolbox from the Microsoft Foundry project, optionally select a subset of its tools, and hand them to an `Agent` alongside any other tools you own: + +```python +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient, select_toolbox_tools + +client = FoundryChatClient(...) +toolbox = await client.get_toolbox("my-toolbox", version="3") + +# Pass the whole toolbox: +agent = Agent(client=client, tools=toolbox) + +# Or filter to a subset first: +selected = select_toolbox_tools(toolbox, include_types=["code_interpreter", "mcp"]) +agent = Agent(client=client, tools=selected) +``` + +See [`foundry_chat_client_with_toolbox.py`](../../samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py) for a full example, including combining multiple toolboxes. + +**2. Connect to the toolbox's MCP endpoint with `MCPStreamableHTTPTool`** + +Each toolbox is reachable as an MCP server. Instead of fetching and fanning out its individual tool definitions, you can point a MAF `MCPStreamableHTTPTool` at the toolbox's MCP endpoint — the agent then discovers and calls its tools over MCP at runtime: + +```python +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.foundry import FoundryChatClient + +async with Agent( + client=FoundryChatClient(...), + instructions="You are a helpful assistant. Use the toolbox tools when useful.", + tools=MCPStreamableHTTPTool( + name="my_toolbox", + description="Tools served by my Foundry toolbox", + url="https://", + ), +) as agent: + result = await agent.run("What tools are available?") + print(result.text) +``` diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index fbd1376735..b70d1720f2 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -16,6 +16,7 @@ from ._foundry_evals import ( evaluate_traces, ) from ._memory_provider import FoundryMemoryProvider +from ._tools import FoundryHostedToolType, get_toolbox_tool_name, get_toolbox_tool_type, select_toolbox_tools try: __version__ = importlib.metadata.version(__name__) @@ -30,6 +31,7 @@ __all__ = [ "FoundryEmbeddingOptions", "FoundryEmbeddingSettings", "FoundryEvals", + "FoundryHostedToolType", "FoundryMemoryProvider", "RawFoundryAgent", "RawFoundryAgentChatClient", @@ -38,4 +40,7 @@ __all__ = [ "__version__", "evaluate_foundry_target", "evaluate_traces", + "get_toolbox_tool_name", + "get_toolbox_tool_type", + "select_toolbox_tools", ] diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index bf5d936d9d..0c7f93ba1f 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -34,6 +34,8 @@ from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from ._tools import sanitize_foundry_response_tool + if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: @@ -307,6 +309,20 @@ class RawFoundryAgentChatClient( # type: ignore[misc] """Skip model check — model is configured on the Foundry agent.""" pass + @override + def _prepare_tools_for_openai( + self, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, + ) -> list[Any]: + """Prepare tools for Foundry agent Responses API calls. + + Mirrors ``RawFoundryChatClient`` sanitization so toolbox-fetched MCP + tools with extra read-model fields continue to work through the agent + surface. + """ + response_tools = super()._prepare_tools_for_openai(tools) + return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: """Extract system/developer messages as instructions for Azure AI. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index d9e2483fbe..7c9eb3a68c 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -16,6 +16,7 @@ from agent_framework import ( load_settings, ) from agent_framework._compaction import CompactionStrategy, TokenizerProtocol +from agent_framework._feature_stage import ExperimentalFeature, experimental from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -32,6 +33,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from ._tools import fetch_toolbox, sanitize_foundry_response_tool + if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: @@ -46,7 +49,8 @@ else: from typing_extensions import TypedDict # type: ignore # pragma: no cover if TYPE_CHECKING: - from agent_framework import ChatAndFunctionMiddlewareTypes + from agent_framework import ChatAndFunctionMiddlewareTypes, ToolTypes + from azure.ai.projects.models import ToolboxVersionObject logger: logging.Logger = logging.getLogger("agent_framework.foundry") @@ -218,6 +222,21 @@ class RawFoundryChatClient( # type: ignore[misc] raise ValueError("model must be a non-empty string") options["model"] = self.model + @override + def _prepare_tools_for_openai( + self, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, + ) -> list[Any]: + """Prepare tools for Foundry Responses API calls. + + Foundry toolbox reads can surface MCP tool objects with extra fields + (for example ``name``) that are accepted by the toolbox API but rejected + by the Responses API. Sanitize those hosted-tool payloads before sending + them downstream. + """ + response_tools = super()._prepare_tools_for_openai(tools) + return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, @@ -460,6 +479,37 @@ class RawFoundryChatClient( # type: ignore[misc] # endregion + # region Toolbox methods (instance methods — these hit the network) + + @experimental(feature_id=ExperimentalFeature.TOOLBOXES) + async def get_toolbox( + self, + name: str, + *, + version: str | None = None, + ) -> ToolboxVersionObject: + """Fetch a Foundry toolbox by name. + + If ``version`` is omitted, resolves the toolbox's current default version + (two requests). If ``version`` is specified, fetches that version directly + (single request). + + Args: + name: The name of the toolbox. + + Keyword Args: + version: Optional immutable version identifier to pin to. + + Returns: + A ``ToolboxVersionObject``. Pass its ``tools`` attribute to + ``Agent(tools=toolbox.tools)``. + + Raises: + azure.core.exceptions.ResourceNotFoundError: If the toolbox or + the requested version does not exist. + """ + return await fetch_toolbox(self.project_client, name, version) + class FoundryChatClient( # type: ignore[misc] FunctionInvocationLayer[FoundryChatOptionsT], diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py new file mode 100644 index 0000000000..3c22872e18 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -0,0 +1,166 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Shared tool helpers for Foundry chat clients. + +Includes: + +* *Toolbox* helpers — a *toolbox* is a named, versioned bundle of tool + definitions stored in an Azure AI Foundry project. +* Responses-API payload sanitization for Foundry hosted tools. +""" + +from __future__ import annotations + +from collections.abc import Callable, Collection, Mapping, Sequence +from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast + +from agent_framework._feature_stage import ExperimentalFeature, experimental +from azure.ai.projects.models import MCPTool as FoundryMCPTool + +if TYPE_CHECKING: + from azure.ai.projects.aio import AIProjectClient + from azure.ai.projects.models import Tool, ToolboxVersionObject + +FoundryHostedToolType: TypeAlias = ( + Literal[ + "code_interpreter", + "file_search", + "image_generation", + "mcp", + "web_search", + ] + | str +) +ToolboxToolSelectionInput: TypeAlias = "ToolboxVersionObject | Sequence[Tool | dict[str, Any]]" + + +@experimental(feature_id=ExperimentalFeature.TOOLBOXES) +async def fetch_toolbox( + project_client: AIProjectClient, + name: str, + version: str | None = None, +) -> ToolboxVersionObject: + """Fetch a toolbox version via an ``AIProjectClient``. + + If ``version`` is omitted, resolves the toolbox's current default + version (two requests: one to ``.get(name)`` for the default version + pointer, one to ``.get_version(name, version)`` for the tools). If + ``version`` is specified, fetches that version directly (single request). + """ + if version is None: + handle = await project_client.beta.toolboxes.get(name) + version = handle.default_version + return await project_client.beta.toolboxes.get_version(name, version) + + +@experimental(feature_id=ExperimentalFeature.TOOLBOXES) +def get_toolbox_tool_name(tool: Tool | dict[str, Any]) -> str | None: + """Return the best-effort display/selection name for a toolbox tool. + + Selection precedence: + 1. MCP ``server_label`` + 2. Generic tool ``name`` + 3. Tool ``type`` + """ + if isinstance(tool, dict): + if server_label := tool.get("server_label"): + return str(server_label) + if name := tool.get("name"): + return str(name) + if tool_type := tool.get("type"): + return str(tool_type) + return None + + if server_label := getattr(tool, "server_label", None): + return str(server_label) + if name := getattr(tool, "name", None): + return str(name) + if tool_type := getattr(tool, "type", None): + return str(tool_type) + return None + + +@experimental(feature_id=ExperimentalFeature.TOOLBOXES) +def get_toolbox_tool_type(tool: Tool | dict[str, Any]) -> str | None: + """Return the raw tool ``type`` if present.""" + tool_type = tool.get("type") if isinstance(tool, dict) else getattr(tool, "type", None) + return str(tool_type) if tool_type is not None else None + + +@experimental(feature_id=ExperimentalFeature.TOOLBOXES) +def select_toolbox_tools( + tools: ToolboxToolSelectionInput, + *, + include_names: Collection[str] | None = None, + exclude_names: Collection[str] | None = None, + include_types: Collection[FoundryHostedToolType] | None = None, + exclude_types: Collection[FoundryHostedToolType] | None = None, + predicate: Callable[[Tool | dict[str, Any]], bool] | None = None, +) -> list[Tool | dict[str, Any]]: + """Filter toolbox tools by normalized name, raw type, and/or predicate. + + Normalized name precedence: + 1. ``server_label`` for MCP tools + 2. ``name`` + 3. ``type`` + """ + tool_items: Sequence[Tool | dict[str, Any]] = ( + tools if isinstance(tools, Sequence) else cast("Sequence[Tool | dict[str, Any]]", tools.tools) + ) + include_name_set = {str(item) for item in include_names} if include_names is not None else None + exclude_name_set = {str(item) for item in exclude_names} if exclude_names is not None else None + include_type_set = {str(item) for item in include_types} if include_types is not None else None + exclude_type_set = {str(item) for item in exclude_types} if exclude_types is not None else None + + selected: list[Tool | dict[str, Any]] = [] + for tool in tool_items: + tool_name = get_toolbox_tool_name(tool) + tool_type = get_toolbox_tool_type(tool) + + if include_name_set is not None and tool_name not in include_name_set: + continue + if exclude_name_set is not None and tool_name in exclude_name_set: + continue + if include_type_set is not None and tool_type not in include_type_set: + continue + if exclude_type_set is not None and tool_type in exclude_type_set: + continue + if predicate is not None and not predicate(tool): + continue + + selected.append(tool) + + return selected + + +@experimental(feature_id=ExperimentalFeature.TOOLBOXES) +def sanitize_foundry_response_tool(tool_item: Any) -> Any: + """Return a Responses-API-safe tool payload for Foundry hosted tools. + + Azure AI Projects toolbox reads can currently return hosted tool objects with + extra read-model decoration fields such as top-level ``name`` and + ``description``. Azure AI Foundry rejects at least ``name`` on Responses API + requests with: + + ``Unknown parameter: 'tools[0].name'``. + + We defensively strip these decoration fields for non-function hosted tools so + the round-trip + ``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure + SDK/service behavior is corrected upstream. + """ + if isinstance(tool_item, FoundryMCPTool): + sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item)) + sanitized.pop("name", None) + sanitized.pop("description", None) + return sanitized + + if isinstance(tool_item, Mapping): + mapping = cast("Mapping[str, Any]", tool_item) + if "type" in mapping and mapping.get("type") not in {"function", "custom"}: + sanitized = dict(mapping) + sanitized.pop("name", None) + sanitized.pop("description", None) + return sanitized + + return cast(Any, tool_item) diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 69d58ee3e5..67feb98c98 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ "agent-framework-core>=1.0.1,<2", "agent-framework-openai>=1.0.1,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", - "azure-ai-projects>=2.0.0,<3.0", + "azure-ai-projects>=2.1.0,<3.0", ] [tool.uv] diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 40fc06d3ef..a7c5beb822 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException +from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential from openai import BadRequestError @@ -608,6 +609,82 @@ def test_get_mcp_tool_with_project_connection_id() -> None: assert tool_config["server_label"] == "Docs_MCP" +def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None: + """Toolbox-returned MCP tools may carry ``name``; Foundry Responses rejects it.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryMCPTool( + server_label="githubmcp", + server_url="https://api.githubcopilot.com/mcp", + ) + tool["project_connection_id"] = "githubmcp" + tool["name"] = "githubmcp" + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "mcp" + assert prepared["server_label"] == "githubmcp" + assert prepared["project_connection_id"] == "githubmcp" + assert "name" not in prepared + + +def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_interpreter() -> None: + """Toolbox-returned code interpreter tools may carry read-model-only name/description.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = { + "type": "code_interpreter", + "name": "code_interpreter_t6bbtm", + "description": "Toolbox read model description", + "container": {"file_ids": [], "type": "auto"}, + } + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "code_interpreter" + assert prepared["container"] == {"file_ids": [], "type": "auto"} + assert "name" not in prepared + assert "description" not in prepared + + +def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None: + """All non-function hosted tool payloads should drop top-level read-model names.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + response_tools = client._prepare_tools_for_openai([ + { + "type": "file_search", + "name": "file_search_tool_123", + "description": "toolbox decoration", + "vector_store_ids": ["vs_123"], + }, + { + "type": "web_search", + "name": "web_search_tool_456", + "description": "toolbox decoration", + }, + ]) + + assert len(response_tools) == 2 + assert response_tools[0]["type"] == "file_search" + assert response_tools[0]["vector_store_ids"] == ["vs_123"] + assert "name" not in response_tools[0] + assert "description" not in response_tools[0] + assert response_tools[1]["type"] == "web_search" + assert "name" not in response_tools[1] + assert "description" not in response_tools[1] + + @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_integration_tests_disabled diff --git a/python/packages/foundry/tests/test_toolbox.py b/python/packages/foundry/tests/test_toolbox.py new file mode 100644 index 0000000000..1933084e10 --- /dev/null +++ b/python/packages/foundry/tests/test_toolbox.py @@ -0,0 +1,435 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for toolbox helpers on FoundryChatClient. + +Return types are the raw azure-ai-projects SDK models (ToolboxVersionObject, +ToolboxObject) — no custom wrapper. Tests verify the chat-client get path and +tool-selection ergonomics. +""" + +from __future__ import annotations + +import datetime as dt +import os +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +try: + from azure.ai.projects.models import ( + AutoCodeInterpreterToolParam, + CodeInterpreterTool, + Tool, + ToolboxObject, + ToolboxVersionObject, + ) +except ImportError: + pytest.skip( + "Toolbox types require azure-ai-projects>=2.1.0 (unreleased).", + allow_module_level=True, + ) + +from azure.core.exceptions import ResourceNotFoundError +from azure.identity import AzureCliCredential + +# --------------------------------------------------------------------------- # +# Helpers # +# --------------------------------------------------------------------------- # + + +class _AsyncIter: + """Minimal async-iterable for mocking ``AsyncItemPaged`` in tests.""" + + def __init__(self, items: list[Any]) -> None: + self._items = items + + def __aiter__(self) -> _AsyncIter: + self._iter = iter(self._items) + return self + + async def __anext__(self) -> Any: + try: + return next(self._iter) + except StopIteration: + raise StopAsyncIteration from None + + +def _make_code_interpreter() -> CodeInterpreterTool: + return CodeInterpreterTool(container=AutoCodeInterpreterToolParam()) + + +def _make_version_object( + *, + name: str = "research_tools", + version: str = "v1", + tools: list[Tool] | None = None, + description: str | None = None, +) -> ToolboxVersionObject: + return ToolboxVersionObject( + id=f"tbv_{name}_{version}", + name=name, + version=version, + metadata={}, + created_at=dt.datetime(2026, 4, 10, tzinfo=dt.timezone.utc), + tools=tools if tools is not None else [_make_code_interpreter()], + description=description, + ) + + +def _make_mock_foundry_client(*, project_client: MagicMock) -> Any: + """Build a FoundryChatClient wired to a mock project_client.""" + from agent_framework_foundry import FoundryChatClient + + project_client.get_openai_client = MagicMock(return_value=MagicMock()) + return FoundryChatClient(project_client=project_client, model="test-model") + + +# --------------------------------------------------------------------------- # +# get_toolbox — explicit version path # +# --------------------------------------------------------------------------- # + + +async def test_get_toolbox_with_explicit_version_makes_single_request() -> None: + project_client = MagicMock() + version_obj = _make_version_object(name="research_tools", version="v3") + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + project_client.beta.toolboxes.get = AsyncMock( + side_effect=AssertionError("get() must not be called when version is explicit") + ) + + client = _make_mock_foundry_client(project_client=project_client) + + toolbox = await client.get_toolbox("research_tools", version="v3") + + assert isinstance(toolbox, ToolboxVersionObject) + assert toolbox.name == "research_tools" + assert toolbox.version == "v3" + project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v3") + project_client.beta.toolboxes.get.assert_not_called() + + +# --------------------------------------------------------------------------- # +# get_toolbox — default-version path + error + passthrough + smoke # +# --------------------------------------------------------------------------- # + + +async def test_get_toolbox_default_version_resolves_then_fetches() -> None: + project_client = MagicMock() + handle = ToolboxObject(id="tb_1", name="research_tools", default_version="v5") + version_obj = _make_version_object(name="research_tools", version="v5") + + project_client.beta.toolboxes.get = AsyncMock(return_value=handle) + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + + client = _make_mock_foundry_client(project_client=project_client) + + toolbox = await client.get_toolbox("research_tools") + + assert toolbox.version == "v5" + project_client.beta.toolboxes.get.assert_awaited_once_with("research_tools") + project_client.beta.toolboxes.get_version.assert_awaited_once_with("research_tools", "v5") + + +async def test_get_toolbox_propagates_resource_not_found() -> None: + project_client = MagicMock() + project_client.beta.toolboxes.get = AsyncMock(side_effect=ResourceNotFoundError("no such toolbox")) + + client = _make_mock_foundry_client(project_client=project_client) + + with pytest.raises(ResourceNotFoundError): + await client.get_toolbox("missing_toolbox") + + +async def test_get_toolbox_tool_passthrough_preserves_heterogeneous_types() -> None: + """Ensure all Tool subclasses pass through unchanged — critical for MCP tools + with project_connection_id, which must reach the runtime untouched.""" + from azure.ai.projects.models import MCPTool as FoundryMCPTool + + mcp_tool = FoundryMCPTool( + server_label="github_oauth", + server_url="https://api.githubcopilot.com/mcp", + ) + mcp_tool["project_connection_id"] = "conn_abc" + + project_client = MagicMock() + version_obj = _make_version_object( + name="mixed", + version="v1", + tools=[_make_code_interpreter(), mcp_tool], + ) + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + + client = _make_mock_foundry_client(project_client=project_client) + + toolbox = await client.get_toolbox("mixed", version="v1") + + assert len(toolbox.tools) == 2 + assert isinstance(toolbox.tools[0], CodeInterpreterTool) + assert isinstance(toolbox.tools[1], FoundryMCPTool) + assert toolbox.tools[1]["project_connection_id"] == "conn_abc" + + +async def test_toolbox_tools_can_be_passed_to_agent() -> None: + """Integration smoke: toolbox.tools can be passed directly to Agent(tools=...) .""" + from agent_framework import Agent + + project_client = MagicMock() + version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()]) + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + + client = _make_mock_foundry_client(project_client=project_client) + + toolbox = await client.get_toolbox("research_tools", version="v1") + + agent = Agent( + client=client, + instructions="You are a test agent.", + tools=toolbox.tools, + ) + + agent_tools = agent.default_options["tools"] + assert len(agent_tools) == 1 + assert agent_tools[0]["type"] == "code_interpreter" + + +async def test_multiple_toolbox_tool_lists_can_be_combined_in_agent() -> None: + """Nested toolbox ``.tools`` lists flatten into one tool list on Agent construction.""" + from agent_framework import Agent + + project_client = MagicMock() + project_client.get_openai_client = MagicMock(return_value=MagicMock()) + client = _make_mock_foundry_client(project_client=project_client) + + toolbox_a = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()]) + toolbox_b = _make_version_object(name="some_other_tools", version="v3", tools=[_make_code_interpreter()]) + + agent = Agent( + client=client, + instructions="You are a test agent.", + tools=[toolbox_a.tools, toolbox_b.tools], + ) + + agent_tools = agent.default_options["tools"] + assert len(agent_tools) == 2 + assert agent_tools[0]["type"] == "code_interpreter" + assert agent_tools[1]["type"] == "code_interpreter" + + +# --------------------------------------------------------------------------- # +# toolbox tool selection helpers # +# --------------------------------------------------------------------------- # + + +def test_get_toolbox_tool_name_prefers_server_label_then_name_then_type() -> None: + from azure.ai.projects.models import MCPTool as FoundryMCPTool + + from agent_framework_foundry import get_toolbox_tool_name + + mcp_tool = FoundryMCPTool( + server_label="githubmcp", + server_url="https://api.githubcopilot.com/mcp", + ) + assert get_toolbox_tool_name(mcp_tool) == "githubmcp" + + named_tool = {"type": "code_interpreter", "name": "ci_tool"} + assert get_toolbox_tool_name(named_tool) == "ci_tool" + + unnamed_tool = {"type": "web_search"} + assert get_toolbox_tool_name(unnamed_tool) == "web_search" + + +def test_select_toolbox_tools_filters_by_names() -> None: + from azure.ai.projects.models import MCPTool as FoundryMCPTool + + from agent_framework_foundry import select_toolbox_tools + + tools: list[Tool | dict[str, Any]] = [ + FoundryMCPTool(server_label="githubmcp", server_url="https://api.githubcopilot.com/mcp"), + {"type": "code_interpreter", "name": "python_runner"}, + {"type": "web_search"}, + ] + + selected = select_toolbox_tools(tools, include_names=["githubmcp", "python_runner"]) + + assert len(selected) == 2 + assert selected[0] is tools[0] + assert selected[1] is tools[1] + + +def test_select_toolbox_tools_filters_by_typed_tool_types() -> None: + from agent_framework_foundry import select_toolbox_tools + + tools: list[Tool | dict[str, Any]] = [ + {"type": "mcp", "server_label": "githubmcp"}, + {"type": "code_interpreter", "name": "python_runner"}, + {"type": "web_search"}, + ] + + selected = select_toolbox_tools(tools, include_types=["mcp", "code_interpreter"]) + + assert len(selected) == 2 + assert selected[0]["type"] == "mcp" + assert selected[1]["type"] == "code_interpreter" + + +def test_select_toolbox_tools_accepts_toolbox_object_directly() -> None: + from agent_framework_foundry import select_toolbox_tools + + toolbox = _make_version_object( + name="research_tools", + version="v1", + tools=[ + {"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item] + {"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item] + {"type": "web_search"}, # type: ignore[list-item] + ], + ) + + selected = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"]) + + assert len(selected) == 2 + assert selected[0]["type"] == "mcp" + assert selected[1]["type"] == "code_interpreter" + + +async def test_fetched_toolbox_can_be_combined_with_function_tool() -> None: + from agent_framework import Agent, FunctionTool, tool + + project_client = MagicMock() + version_obj = _make_version_object(name="research_tools", version="v1", tools=[_make_code_interpreter()]) + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + + client = _make_mock_foundry_client(project_client=project_client) + toolbox = await client.get_toolbox("research_tools", version="v1") + + @tool(name="local_lookup", description="A local helper tool") + def local_lookup(query: str) -> str: + return query + + agent = Agent( + client=client, + instructions="You are a test agent.", + tools=[toolbox, local_lookup], + ) + + agent_tools = agent.default_options["tools"] + assert len(agent_tools) == 2 + assert agent_tools[0]["type"] == "code_interpreter" + assert isinstance(agent_tools[1], FunctionTool) + assert agent_tools[1].name == "local_lookup" + + +def test_select_toolbox_tools_supports_excludes_and_predicate() -> None: + from agent_framework_foundry import select_toolbox_tools + + tools: list[Tool | dict[str, Any]] = [ + {"type": "mcp", "server_label": "githubmcp"}, + {"type": "mcp", "server_label": "learnmcp"}, + {"type": "web_search"}, + ] + + selected = select_toolbox_tools( + tools, + exclude_names=["learnmcp"], + predicate=lambda tool: tool.get("type") == "mcp", # type: ignore[union-attr] + ) + + assert len(selected) == 1 + assert selected[0]["server_label"] == "githubmcp" + + +async def test_selected_toolbox_subset_can_be_combined_with_function_tool() -> None: + from agent_framework import Agent, FunctionTool, tool + + from agent_framework_foundry import select_toolbox_tools + + project_client = MagicMock() + version_obj = _make_version_object( + name="research_tools", + version="v1", + tools=[ + {"type": "mcp", "server_label": "githubmcp"}, # type: ignore[list-item] + {"type": "code_interpreter", "name": "python_runner"}, # type: ignore[list-item] + {"type": "web_search"}, # type: ignore[list-item] + ], + ) + project_client.beta.toolboxes.get_version = AsyncMock(return_value=version_obj) + + client = _make_mock_foundry_client(project_client=project_client) + toolbox = await client.get_toolbox("research_tools", version="v1") + selected_tools = select_toolbox_tools(toolbox, include_types=["mcp", "code_interpreter"]) + + @tool(name="local_lookup", description="A local helper tool") + def local_lookup(query: str) -> str: + return query + + agent = Agent( + client=client, + instructions="You are a test agent.", + tools=[selected_tools, local_lookup], + ) + + agent_tools = agent.default_options["tools"] + assert len(agent_tools) == 3 + assert agent_tools[0]["type"] == "mcp" + assert agent_tools[1]["type"] == "code_interpreter" + assert isinstance(agent_tools[2], FunctionTool) + assert agent_tools[2].name == "local_lookup" + + +# --------------------------------------------------------------------------- # +# Integration # +# --------------------------------------------------------------------------- # + + +skip_if_foundry_integration_tests_disabled = pytest.mark.skipif( + os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/") + or os.getenv("FOUNDRY_MODEL", "") == "", + reason="No real FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_MODEL provided; skipping integration tests.", +) + + +@pytest.mark.flaky +@pytest.mark.integration +@skip_if_foundry_integration_tests_disabled +async def test_integration_get_toolbox_round_trip_against_real_project() -> None: + """Create a toolbox via the raw SDK, fetch via FoundryChatClient, then delete. + + Self-contained to avoid depending on toolboxes that may be cleaned up + externally. Exercises both the default-version resolution path + (``get`` + ``get_version``) and the explicit-version path. + """ + from uuid import uuid4 + + from agent_framework import Agent + + from agent_framework_foundry import FoundryChatClient + + client = FoundryChatClient(credential=AzureCliCredential()) + project_client = client.project_client + + toolbox_name = f"af-int-toolbox-{uuid4().hex[:12]}" + created = await project_client.beta.toolboxes.create_version( + name=toolbox_name, + tools=[CodeInterpreterTool()], + description=f"{toolbox_name} integration test", + ) + assert isinstance(created, ToolboxVersionObject) + try: + toolbox_default = await client.get_toolbox(toolbox_name) + assert toolbox_default.name == toolbox_name + assert toolbox_default.tools, "Default-version fetch returned no tools" + + toolbox_pinned = await client.get_toolbox(toolbox_name, version=created.version) + assert toolbox_pinned.version == created.version + assert toolbox_pinned.tools + + agent = Agent( + client=client, + instructions="You are a test agent.", + tools=toolbox_pinned.tools, + ) + assert len(agent.default_options["tools"]) == len(toolbox_pinned.tools) + finally: + await project_client.beta.toolboxes.delete(toolbox_name) diff --git a/python/samples/02-agents/context_providers/README.md b/python/samples/02-agents/context_providers/README.md index 04f3a1395f..7c34e10518 100644 --- a/python/samples/02-agents/context_providers/README.md +++ b/python/samples/02-agents/context_providers/README.md @@ -7,6 +7,7 @@ These samples demonstrate how to use context providers to enrich agent conversat | File / Folder | Description | |---------------|-------------| | [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. | +| [`foundry_toolbox_context_provider.py`](foundry_toolbox_context_provider.py) | Compose a Microsoft Foundry toolbox with a `ContextProvider` that caches the toolbox once and picks a subset of its tools per-turn via `select_toolbox_tools`, driven by keywords in the latest user message. | | [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Azure AI Foundry. | | [`azure_ai_search/`](azure_ai_search/) | Retrieval Augmented Generation (RAG) with Azure AI Search in semantic and agentic modes. See its own [README](azure_ai_search/README.md). | | [`mem0/`](mem0/) | Memory-powered context using the Mem0 integration (open-source and managed). See its own [README](mem0/README.md). | @@ -19,6 +20,12 @@ These samples demonstrate how to use context providers to enrich agent conversat - `FOUNDRY_MODEL`: Model deployment name - Azure CLI authentication (`az login`) +**For `foundry_toolbox_context_provider.py`:** +- `FOUNDRY_PROJECT_ENDPOINT`: Your Microsoft Foundry project endpoint +- `FOUNDRY_MODEL`: Model deployment name +- A toolbox already configured in that project; set `TOOLBOX_NAME` / `TOOLBOX_VERSION` at the top of the sample +- Azure CLI authentication (`az login`) + **For `azure_ai_foundry_memory.py`:** - `FOUNDRY_PROJECT_ENDPOINT`: Your Azure AI Foundry project endpoint - `FOUNDRY_MODEL`: Chat/responses model deployment name diff --git a/python/samples/02-agents/context_providers/foundry_toolbox_context_provider.py b/python/samples/02-agents/context_providers/foundry_toolbox_context_provider.py new file mode 100644 index 0000000000..d889c7c1ac --- /dev/null +++ b/python/samples/02-agents/context_providers/foundry_toolbox_context_provider.py @@ -0,0 +1,207 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from typing import Any + +from agent_framework import Agent, AgentSession, ContextProvider, Message, SessionContext +from agent_framework.foundry import ( + FoundryChatClient, + get_toolbox_tool_name, + get_toolbox_tool_type, + select_toolbox_tools, +) +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import BaseModel + +# Load environment variables from .env file +load_dotenv() + +""" +Foundry Toolbox + Context Provider Example + +This sample composes a Foundry toolbox with a ContextProvider so the agent's +tool list is chosen dynamically per-turn. It uses the chat client itself as a lightweight "tool router": the +latest user message plus a short menu of toolbox tools is sent to the model +with a Pydantic ``response_format``, and the returned tool names drive +``select_toolbox_tools``. The toolbox is fetched once and cached on the +provider's state dict; subsequent turns reuse the cache. + +Prerequisites: +- A Microsoft Foundry project +- A toolbox already configured in that project (set TOOLBOX_NAME below) +- FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL environment variables set +- Azure CLI authentication (`az login`) +""" + +# Replace with your own Foundry toolbox name and version. +TOOLBOX_NAME = "research_toolbox" +# Set to None to resolve the toolbox's current default version at fetch time. +TOOLBOX_VERSION: str | None = None + +# Generic queries that exercise the router without assuming any specific tool +# types are configured. The first is introspective, the second forces a +# non-empty pick for whichever tools the toolbox actually contains, and the +# third should route to nothing. +QUERIES: list[str] = [ + "Introduce yourself and briefly describe the tools you can use to help me.", + "Pick the tool you think is most useful and demonstrate it with a short example.", + "Say hi in one short sentence - no tools needed.", +] + + +def create_sample_toolbox(name: str) -> str: + """Create (or replace) a toolbox version in the Foundry project. + + Toolboxes are normally configured in the Foundry portal or a deployment + script, not the application itself. This helper exists so the sample can + be run end-to-end without first setting a toolbox up by hand — delete any + existing toolbox under ``name``, then create a fresh version containing a + single MCP tool. Returns the created version identifier. + """ + from azure.ai.projects import AIProjectClient + from azure.ai.projects.models import MCPTool, Tool + from azure.core.exceptions import ResourceNotFoundError + + with ( + AzureCliCredential() as credential, + AIProjectClient(credential=credential, endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"]) as project_client, + ): + try: + project_client.beta.toolboxes.delete(name) + print(f"Toolbox `{name}` deleted") + except ResourceNotFoundError: + pass + + tools: list[Tool] = [ + MCPTool( + server_label="api_specs", + server_url="https://gitmcp.io/Azure/azure-rest-api-specs", + require_approval="never", + ) + ] + + created = project_client.beta.toolboxes.create_version( + name=name, + description="Toolbox version with MCP require_approval set to 'never'.", + tools=tools, + ) + print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s))") + return created.version + + +class ToolSelection(BaseModel): + """Structured output for the per-turn tool router.""" + + tool_names: list[str] + + +ROUTER_INSTRUCTIONS = ( + "You are a tool router. Given the user's latest message and a menu of " + "available tools (one per line, formatted as 'NAME - TYPE'), return the " + "NAMES of the tools that would plausibly help answer the message. Return " + "an empty list if no tool is needed." +) + + +class DynamicToolboxProvider(ContextProvider): + """Fetches a Foundry toolbox once and lets the model pick tools per-turn.""" + + DEFAULT_SOURCE_ID = "foundry_toolbox" + + def __init__( + self, + source_id: str = DEFAULT_SOURCE_ID, + *, + client: FoundryChatClient, + toolbox_name: str, + toolbox_version: str | None = None, + ) -> None: + super().__init__(source_id) + self._client = client + self._toolbox_name = toolbox_name + self._toolbox_version = toolbox_version + + async def before_run( + self, + *, + agent: Any, + session: AgentSession | None, + context: SessionContext, + state: dict[str, Any], + ) -> None: + """Cache the toolbox on first call, then let the model pick tools per-turn.""" + toolbox = state.get("toolbox") + if toolbox is None: + toolbox = await self._client.get_toolbox(self._toolbox_name, version=self._toolbox_version) + state["toolbox"] = toolbox + print(f"[{self.source_id}] Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tool(s))") + + user_messages = [m for m in context.get_messages(include_input=True) if getattr(m, "role", None) == "user"] + if not user_messages: + context.extend_tools(self.source_id, list(toolbox.tools)) + return + + picks = await self._route_tools(user_messages[-1].text, toolbox.tools) + if picks: + tools = select_toolbox_tools(toolbox, include_names=picks) + print(f"[{self.source_id}] Router picked {sorted(picks)} - surfacing {len(tools)} tool(s)") + else: + tools = list(toolbox.tools) + print(f"[{self.source_id}] Router picked nothing - surfacing all {len(tools)} tool(s)") + context.extend_tools(self.source_id, tools) + + async def _route_tools(self, user_text: str, tools: Any) -> list[str]: + """Ask the model which toolbox tools to surface for this turn.""" + menu = "\n".join(f"- {get_toolbox_tool_name(t)} - {get_toolbox_tool_type(t)}" for t in tools) + prompt = ( + f"User message:\n{user_text}\n\n" + f"Available tools:\n{menu}\n\n" + "Return the names of tools that should be surfaced for this turn." + ) + response = await self._client.get_response( + messages=[Message("user", [prompt])], + options={ + "instructions": ROUTER_INSTRUCTIONS, + "response_format": ToolSelection, + }, + ) + selection: ToolSelection = response.value # type: ignore + return selection.tool_names + + +async def main() -> None: + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=AzureCliCredential(), + ) + + # Comment out if the toolbox already exists in your Foundry project. + create_sample_toolbox(TOOLBOX_NAME) + + toolbox_provider = DynamicToolboxProvider( + client=client, + toolbox_name=TOOLBOX_NAME, + toolbox_version=TOOLBOX_VERSION, + ) + + async with Agent( + client=client, + instructions=( + "You are a helpful assistant. Use the tools available to you on each " + "turn to answer the user. If no tools are relevant, reply directly." + ), + context_providers=[toolbox_provider], + ) as agent: + session = agent.create_session() + + for query in QUERIES: + print(f"\nUser: {query}") + result = await agent.run(query, session=session) + print(f"Assistant: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/02-agents/providers/foundry/README.md b/python/samples/02-agents/providers/foundry/README.md index 2025b0f4fe..120c4d9a1c 100644 --- a/python/samples/02-agents/providers/foundry/README.md +++ b/python/samples/02-agents/providers/foundry/README.md @@ -26,6 +26,8 @@ This folder contains Azure AI Foundry and Foundry Local samples for Agent Framew | [`foundry_chat_client_with_hosted_mcp.py`](foundry_chat_client_with_hosted_mcp.py) | Foundry Chat Client with hosted MCP | | [`foundry_chat_client_with_local_mcp.py`](foundry_chat_client_with_local_mcp.py) | Foundry Chat Client with local MCP | | [`foundry_chat_client_with_session.py`](foundry_chat_client_with_session.py) | Foundry Chat Client with session management | +| [`foundry_chat_client_with_toolbox.py`](foundry_chat_client_with_toolbox.py) | Foundry Chat Client with Foundry toolbox loading and multi-toolbox composition | +| [`foundry_chat_client_with_toolbox_mcp.py`](foundry_chat_client_with_toolbox_mcp.py) | Foundry Chat Client connected to a toolbox via its MCP endpoint using `MCPStreamableHTTPTool` | ## FoundryLocalClient Samples diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py new file mode 100644 index 0000000000..8a532331ae --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py @@ -0,0 +1,174 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient, select_toolbox_tools +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Foundry Chat Client with Toolbox Example + +This sample demonstrates loading a named, versioned Foundry toolbox into an +Agent via ``FoundryChatClient.get_toolbox()``. A toolbox is a server-side +bundle of tool configurations (code interpreter, file search, MCP, web search, +etc.) configured in the Foundry portal or via the raw SDK. + +Prerequisites: +- A Microsoft Foundry project +- A toolbox already configured in that project (set TOOLBOX_NAME below) +- FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL environment variables set +""" + +# Replace with your own Foundry toolbox name and version. +TOOLBOX_NAME = "research_toolbox" +TOOLBOX_VERSION = "1" +# Used only by combine_toolboxes() — swap in a second toolbox you own. +SECOND_TOOLBOX_NAME = "analysis_toolbox" +SECOND_TOOLBOX_VERSION = "1" + +# Replace with any question that exercises the tools configured in your toolbox. +QUERY = "Introduce yourself and briefly describe the tools you can use to help me." + + +def create_sample_toolbox(name: str) -> str: + """Create (or replace) a toolbox version in the Foundry project. + + Toolboxes are normally configured in the Foundry portal or a deployment + script, not the application itself. This helper exists so the samples can + be run end-to-end without first setting a toolbox up by hand — delete any + existing toolbox under ``name``, then create a fresh version containing a + single MCP tool. Returns the created version identifier. + """ + from azure.ai.projects import AIProjectClient + from azure.ai.projects.models import MCPTool, Tool + from azure.core.exceptions import ResourceNotFoundError + + with ( + AzureCliCredential() as credential, + AIProjectClient(credential=credential, endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"]) as project_client, + ): + try: + project_client.beta.toolboxes.delete(name) + print(f"Toolbox `{name}` deleted") + except ResourceNotFoundError: + pass + + tools: list[Tool] = [ + MCPTool( + server_label="api_specs", + server_url="https://gitmcp.io/Azure/azure-rest-api-specs", + require_approval="never", + ) + ] + + created = project_client.beta.toolboxes.create_version( + name=name, + description="Toolbox version with MCP require_approval set to 'never'.", + tools=tools, + ) + print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s))") + return created.version + + +async def main() -> None: + """Example showing how to use a single Foundry toolbox with FoundryChatClient.""" + print("=== Foundry Chat Client with Toolbox Example ===") + + # For authentication, run `az login` in your terminal or replace + # AzureCliCredential with your preferred authentication option. + client = FoundryChatClient( + credential=AzureCliCredential(), + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + ) + + # Comment out if the toolbox already exists in your Foundry project. + create_sample_toolbox(TOOLBOX_NAME) + + # Omit ``version`` to resolve the toolbox's current default version at runtime. + toolbox = await client.get_toolbox(TOOLBOX_NAME) + print(f"Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tool(s))") + + agent = Agent( + client=client, + instructions="You are a research assistant. Use the available tools to answer questions.", + tools=toolbox, + ) + + print(f"User: {QUERY}") + result = await agent.run(QUERY) + print(f"Result: {result}\n") + + +async def combine_toolboxes() -> None: + """Alternative flow: combine the tools from multiple Foundry toolboxes.""" + client = FoundryChatClient( + credential=AzureCliCredential(), + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + ) + + # Comment out if the toolboxes already exist in your Foundry project. + create_sample_toolbox(TOOLBOX_NAME) + create_sample_toolbox(SECOND_TOOLBOX_NAME) + + toolbox_a = await client.get_toolbox(TOOLBOX_NAME, version=TOOLBOX_VERSION) + toolbox_b = await client.get_toolbox(SECOND_TOOLBOX_NAME, version=SECOND_TOOLBOX_VERSION) + print( + "Loaded toolboxes: " + f"{toolbox_a.name}@{toolbox_a.version} ({len(toolbox_a.tools)} tool(s)), " + f"{toolbox_b.name}@{toolbox_b.version} ({len(toolbox_b.tools)} tool(s))" + ) + + agent = Agent( + client=client, + instructions="You are a research assistant. Use all available tools to answer questions.", + tools=[toolbox_a, toolbox_b], + ) + + print(f"User: {QUERY}") + result = await agent.run(QUERY) + print(f"Combined-toolbox result: {result}\n") + + +async def select_tools_from_toolbox() -> None: + """Alternative flow: keep only a subset of toolbox tools before agent creation.""" + client = FoundryChatClient( + credential=AzureCliCredential(), + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + ) + + # Comment out if the toolbox already exists in your Foundry project. + create_sample_toolbox(TOOLBOX_NAME) + + toolbox = await client.get_toolbox(TOOLBOX_NAME, version=TOOLBOX_VERSION) + print(f"Loaded toolbox {toolbox.name}@{toolbox.version} ({len(toolbox.tools)} tool(s))") + + selected_tools = select_toolbox_tools( + toolbox, + include_types=["code_interpreter", "mcp"], + ) + print(f"Selected {len(selected_tools)} toolbox tools for the agent") + + agent = Agent( + client=client, + instructions="You are a research assistant. Use only the selected toolbox tools.", + tools=selected_tools, + ) + + print(f"User: {QUERY}") + result = await agent.run(QUERY) + print(f"Selected-toolbox result: {result}\n") + + +if __name__ == "__main__": + asyncio.run(main()) + # asyncio.run(combine_toolboxes()) + # asyncio.run(select_tools_from_toolbox()) diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_mcp.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_mcp.py new file mode 100644 index 0000000000..1fbfe20a9a --- /dev/null +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox_mcp.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os +from collections.abc import Callable +from typing import Any + +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.foundry import FoundryChatClient +from azure.core.credentials import TokenCredential +from azure.identity import AzureCliCredential, DefaultAzureCredential, get_bearer_token_provider +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Foundry Toolbox via MAF ``MCPStreamableHTTPTool`` + +Instead of fetching the toolbox and fanning out individual tool specs, point +MAF's ``MCPStreamableHTTPTool`` at the toolbox's MCP endpoint. The agent +discovers and calls the toolbox's tools over MCP at runtime. + +Prerequisites: +- A Microsoft Foundry project with a toolbox configured +- FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL environment variables set +- FOUNDRY_TOOLBOX_ENDPOINT: the toolbox's MCP endpoint URL, e.g. + ``https://.services.ai.azure.com/api/projects//toolsets//mcp?api-version=v1`` +- Azure CLI authentication (``az login``) +""" + +# Must match the ```` segment of FOUNDRY_TOOLBOX_ENDPOINT. +TOOLBOX_NAME = "research_toolbox" + + +def create_sample_toolbox(name: str) -> str: + """Create (or replace) a toolbox version in the Foundry project. + + Toolboxes are normally configured in the Foundry portal or a deployment + script, not the application itself. This helper exists so the sample can + be run end-to-end without first setting a toolbox up by hand — delete any + existing toolbox under ``name``, then create a fresh version containing a + single MCP tool. Returns the created version identifier. + """ + from azure.ai.projects import AIProjectClient + from azure.ai.projects.models import MCPTool, Tool + from azure.core.exceptions import ResourceNotFoundError + + with ( + AzureCliCredential() as credential, + AIProjectClient(credential=credential, endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"]) as project_client, + ): + try: + project_client.beta.toolboxes.delete(name) + print(f"Toolbox `{name}` deleted") + except ResourceNotFoundError: + pass + + tools: list[Tool] = [ + MCPTool( + server_label="api_specs", + server_url="https://gitmcp.io/Azure/azure-rest-api-specs", + require_approval="never", + ) + ] + + created = project_client.beta.toolboxes.create_version( + name=name, + description="Toolbox version with MCP require_approval set to 'never'.", + tools=tools, + ) + print(f"Created toolbox {created.name}@{created.version} ({len(created.tools)} tool(s))") + return created.version + + +def make_toolbox_header_provider(credential: TokenCredential) -> Callable[[dict[str, Any]], dict[str, str]]: + """Build a header_provider that injects a fresh Azure AI bearer token on every MCP request.""" + get_token = get_bearer_token_provider(credential, "https://ai.azure.com/.default") + + def provide(_kwargs: dict[str, Any]) -> dict[str, str]: + return { + "Authorization": f"Bearer {get_token()}", + } + + return provide + + +async def main() -> None: + credential = DefaultAzureCredential() + + # Comment out if the toolbox already exists in your Foundry project. + create_sample_toolbox(TOOLBOX_NAME) + + toolbox_tool = MCPStreamableHTTPTool( + name="foundry_toolbox", + description="Tools exposed by the configured Foundry toolbox", + url=os.environ["FOUNDRY_TOOLBOX_ENDPOINT"], + header_provider=make_toolbox_header_provider(credential), + load_prompts=False, + ) + + async with Agent( + client=FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_MODEL"], + credential=credential, + ), + instructions="You are a helpful assistant. Use the available toolbox tools to answer the user.", + tools=toolbox_tool, + ) as agent: + query = "What tools do you have access to?" + print(f"User: {query}") + result = await agent.run(query) + print(f"Assistant: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/uv.lock b/python/uv.lock index 370fd7e46d..92fc51361d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -496,7 +496,7 @@ requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, { name = "agent-framework-openai", editable = "packages/openai" }, { name = "azure-ai-inference", specifier = ">=1.0.0b9,<1.0.0b10" }, - { name = "azure-ai-projects", specifier = ">=2.0.0,<3.0" }, + { name = "azure-ai-projects", specifier = ">=2.1.0,<3.0" }, ] [[package]] @@ -1048,7 +1048,7 @@ wheels = [ [[package]] name = "azure-ai-projects" -version = "2.0.1" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1058,9 +1058,9 @@ dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/86/f9/a15c8a16e35e6d620faebabc6cc4f9e2f4b7f1d962cc6f58931c46947e24/azure_ai_projects-2.0.1.tar.gz", hash = "sha256:c8c64870aa6b89903af69a4ff28b4eff3df9744f14615ea572cae87394946a0c", size = 491774, upload-time = "2026-03-12T19:59:02.712Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/76/3fdede8eddfe5927a571898a15f0288ba30fee78e5ba099f88df3ded70af/azure_ai_projects-2.1.0.tar.gz", hash = "sha256:f0749fa9a174255aa1a5550fb6078208521518472907a4c6dd552767d9b39caa", size = 543343, upload-time = "2026-04-20T17:06:48.751Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/f7/290ca39501c06c6e23b46ba9f7f3dfb05ecc928cde105fed85d6845060dd/azure_ai_projects-2.0.1-py3-none-any.whl", hash = "sha256:dfda540d256e67a52bf81c75418b6bf92b811b96693fe45787e154a888ad2396", size = 236560, upload-time = "2026-03-12T19:59:04.249Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f6/4984e7772a97c7a9e6505a3de8e55a5070fa2b02cd7e980da91e0d9b9b97/azure_ai_projects-2.1.0-py3-none-any.whl", hash = "sha256:6f259d8eb9167d2dfd372006d0221a8118faeaeb05829fa898b595bc6f19c699", size = 274309, upload-time = "2026-04-20T17:06:50.542Z" }, ] [[package]] From 07f4c8a8d66d2fba40bdd086f16cc6dca059d054 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:25:45 +0900 Subject: [PATCH 10/52] Python: Expose forwardedProps to agents and tools via session metadata (#5264) * Expose forwarded_props to agents and tools via session metadata (#5239) Include forwarded_props from AG-UI request input_data in session.metadata (agent runner) and function_invocation_kwargs (workflow runner) so that agents, tools, and workflow executors can access request-level metadata such as invocation source flags from CopilotKit. - Add forwarded_props to base_metadata in _agent_run.py when present - Add 'forwarded_props' to AG_UI_INTERNAL_METADATA_KEYS to filter it from LLM-bound client metadata - Extract forwarded_props in _workflow_run.py and pass via function_invocation_kwargs to workflow.run() - Accept both snake_case and camelCase keys (forwarded_props/forwardedProps) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ag-ui): pass stream=True as literal to satisfy pyright overload resolution (#5239) The previous fix passed stream=True via **kwargs dict, which prevented pyright from resolving the Workflow.run() overload to the streaming variant. Pass stream=True as an explicit keyword argument so pyright can correctly infer the ResponseStream return type. Also remove unused pytest import in test file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review feedback for forwarded_props (#5239) - Use key-presence checks instead of truthiness for forwarded_props so empty dict {} is forwarded correctly - Gate function_invocation_kwargs on workflow.run() signature inspection to avoid TypeError for workflows without **kwargs - Change _build_safe_metadata to drop (with warning) keys whose serialized values exceed 512 chars instead of truncating into invalid JSON - Rewrite metadata tests to exercise _build_safe_metadata directly with JSON-decodability and truncation assertions - Add workflow tests for empty dict forwarded_props, stream=True assertion, and signature-gated kwarg dropping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: add stream=True assertions to CapturingWorkflow tests (#5239) Guard against accidental removal of the explicit stream=True kwarg in all forwarded_props CapturingWorkflow test cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5239: Python: Expose forwardedProps to agents and tools via session metadata --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 23 +- .../agent_framework_ag_ui/_workflow_run.py | 27 ++- .../ag_ui/test_forwarded_props_in_metadata.py | 83 +++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 12 +- .../ag-ui/tests/ag_ui/test_workflow_run.py | 207 ++++++++++++++++++ 5 files changed, 339 insertions(+), 13 deletions(-) create mode 100644 python/packages/ag-ui/tests/ag_ui/test_forwarded_props_in_metadata.py diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 639a3f89b3..330a66dc10 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -69,19 +69,23 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) # Keys that are internal to AG-UI orchestration and should not be passed to chat clients -AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state"} +AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state", "forwarded_props"} def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]: - """Build metadata dict with truncated string values for Azure compatibility. + """Build metadata dict with string values for Azure compatibility. - Azure has a 512 character limit per metadata value. + Azure has a 512 character limit per metadata value. String values that + already fit are kept as-is. Non-string values are JSON-serialized. If the + resulting string exceeds 512 characters the key is **dropped** (with a + warning) instead of truncated, because truncation can produce invalid JSON + that downstream consumers cannot decode. Args: thread_metadata: Raw metadata dict Returns: - Metadata with string values truncated to 512 chars + Metadata with safe string values (each <= 512 chars) """ if not thread_metadata: return {} @@ -89,7 +93,12 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An for key, value in thread_metadata.items(): value_str = value if isinstance(value, str) else json.dumps(value) if len(value_str) > 512: - value_str = value_str[:512] + logger.warning( + "Dropping metadata key %r: serialized value is %d chars (limit 512)", + key, + len(value_str), + ) + continue safe_metadata[key] = value_str return safe_metadata @@ -790,6 +799,10 @@ async def run_agent_stream( "ag_ui_thread_id": thread_id, "ag_ui_run_id": run_id, } + if "forwarded_props" in input_data: + base_metadata["forwarded_props"] = input_data["forwarded_props"] + elif "forwardedProps" in input_data: + base_metadata["forwarded_props"] = input_data["forwardedProps"] if flow.current_state: base_metadata["current_state"] = flow.current_state session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined] diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py index d34cb7db61..211657e688 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_workflow_run.py @@ -4,6 +4,7 @@ from __future__ import annotations +import inspect import json import logging import uuid @@ -581,11 +582,33 @@ async def run_workflow_stream( flow.accumulated_text = "" return [TextMessageEndEvent(message_id=current_message_id)] + fwd_kwargs: dict[str, Any] = {} + if "forwarded_props" in input_data: + forwarded_props = input_data["forwarded_props"] + fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props} + elif "forwardedProps" in input_data: + forwarded_props = input_data["forwardedProps"] + fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props} + + # Only pass function_invocation_kwargs if the workflow.run signature accepts it + if fwd_kwargs: + try: + sig = inspect.signature(workflow.run) + params = sig.parameters + accepts_fwd = "function_invocation_kwargs" in params or any( + p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + except (ValueError, TypeError): + accepts_fwd = False + if not accepts_fwd: + logger.debug("workflow.run() does not accept function_invocation_kwargs; dropping forwarded_props") + fwd_kwargs = {} + try: if responses: - event_stream = workflow.run(responses=responses, stream=True) + event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs) else: - event_stream = workflow.run(message=messages, stream=True) + event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs) async for event in event_stream: event_type = getattr(event, "type", None) diff --git a/python/packages/ag-ui/tests/ag_ui/test_forwarded_props_in_metadata.py b/python/packages/ag-ui/tests/ag_ui/test_forwarded_props_in_metadata.py new file mode 100644 index 0000000000..fba70db17e --- /dev/null +++ b/python/packages/ag-ui/tests/ag_ui/test_forwarded_props_in_metadata.py @@ -0,0 +1,83 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for forwarded_props inclusion in AG-UI session metadata.""" + +import json +from typing import Any + +from agent_framework_ag_ui._agent_run import AG_UI_INTERNAL_METADATA_KEYS, _build_safe_metadata + + +class TestForwardedPropsInSessionMetadata: + """Verify that forwarded_props is surfaced in session metadata and filtered from LLM metadata.""" + + def test_forwarded_props_in_internal_metadata_keys(self): + """forwarded_props is listed in AG_UI_INTERNAL_METADATA_KEYS to prevent LLM leakage.""" + assert "forwarded_props" in AG_UI_INTERNAL_METADATA_KEYS + + def test_forwarded_props_filtered_from_client_metadata(self): + """forwarded_props is filtered out when building LLM-bound client metadata.""" + session_metadata: dict[str, Any] = { + "ag_ui_thread_id": "t1", + "ag_ui_run_id": "r1", + "forwarded_props": '{"custom_flag": true}', + } + + client_metadata = {k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS} + + assert "forwarded_props" not in client_metadata + assert "ag_ui_thread_id" not in client_metadata + + +class TestBuildSafeMetadata: + """Verify _build_safe_metadata handles various value types correctly.""" + + def test_string_value_unchanged(self): + result = _build_safe_metadata({"key": "hello"}) + assert result == {"key": "hello"} + + def test_dict_value_serialized_to_json(self): + result = _build_safe_metadata({"fp": {"flag": True, "source": "frontend"}}) + assert "fp" in result + assert isinstance(result["fp"], str) + # Must be valid, decodable JSON + decoded = json.loads(result["fp"]) + assert decoded == {"flag": True, "source": "frontend"} + + def test_empty_dict_serialized_to_json(self): + result = _build_safe_metadata({"fp": {}}) + assert result["fp"] == "{}" + assert json.loads(result["fp"]) == {} + + def test_value_within_limit_kept(self): + value = "x" * 512 + result = _build_safe_metadata({"key": value}) + assert result["key"] == value + + def test_value_exceeding_limit_dropped(self): + """Values exceeding 512 chars are dropped entirely (not truncated).""" + value = "x" * 513 + result = _build_safe_metadata({"key": value}) + assert "key" not in result + + def test_json_value_exceeding_limit_dropped(self): + """JSON-serialized dict exceeding 512 chars is dropped, not truncated into invalid JSON.""" + big_dict = {f"key_{i}": "v" * 100 for i in range(50)} + result = _build_safe_metadata({"forwarded_props": big_dict}) + assert "forwarded_props" not in result + + def test_other_keys_preserved_when_one_dropped(self): + """Dropping one oversized key does not affect other keys.""" + result = _build_safe_metadata( + { + "small": "ok", + "big": "x" * 600, + } + ) + assert result == {"small": "ok"} + + def test_none_input_returns_empty(self): + assert _build_safe_metadata(None) == {} + + def test_empty_input_returns_empty(self): + assert _build_safe_metadata({}) == {} diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 18b0d0d7e4..392f0cd723 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -63,12 +63,12 @@ class TestBuildSafeMetadata: result = _build_safe_metadata(metadata) assert result == metadata - def test_truncates_long_strings(self): - """Truncates strings over 512 chars.""" + def test_drops_long_strings(self): + """Drops strings over 512 chars instead of truncating.""" long_value = "x" * 1000 metadata = {"key": long_value} result = _build_safe_metadata(metadata) - assert len(result["key"]) == 512 + assert "key" not in result def test_serializes_non_strings(self): """Serializes non-string values to JSON.""" @@ -77,12 +77,12 @@ class TestBuildSafeMetadata: assert result["count"] == "42" assert result["items"] == "[1, 2, 3]" - def test_truncates_serialized_values(self): - """Truncates serialized values over 512 chars.""" + def test_drops_oversized_serialized_values(self): + """Drops serialized values over 512 chars instead of truncating.""" long_list = list(range(200)) metadata = {"data": long_list} result = _build_safe_metadata(metadata) - assert len(result["data"]) == 512 + assert "data" not in result class TestHasOnlyToolCalls: diff --git a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py index 26b44b03ba..a52cc4dd2c 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_workflow_run.py @@ -1672,3 +1672,210 @@ async def test_workflow_run_non_terminal_status_emits_custom(): custom = [e for e in events if e.type == "CUSTOM" and e.name == "status"] assert len(custom) == 1 assert custom[0].value == {"state": "running"} + + +async def test_workflow_run_passes_forwarded_props_as_function_invocation_kwargs() -> None: + """forwarded_props from input_data is forwarded to workflow.run() via function_invocation_kwargs.""" + + class CapturingWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, **kwargs: Any): + self.captured_kwargs = dict(kwargs) + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = CapturingWorkflow() + events = [ + event + async for event in run_workflow_stream( + { + "messages": [{"role": "user", "content": "hello"}], + "forwarded_props": {"custom_flag": True, "source": "copilotkit"}, + }, + cast(Any, workflow), + ) + ] + + event_types = [event.type for event in events] + assert "RUN_STARTED" in event_types + assert "RUN_FINISHED" in event_types + + assert workflow.captured_kwargs["stream"] is True + assert "function_invocation_kwargs" in workflow.captured_kwargs + assert workflow.captured_kwargs["function_invocation_kwargs"] == { + "forwarded_props": {"custom_flag": True, "source": "copilotkit"}, + } + + +async def test_workflow_run_omits_function_invocation_kwargs_when_no_forwarded_props() -> None: + """function_invocation_kwargs is not passed when forwarded_props is absent.""" + + class CapturingWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, **kwargs: Any): + self.captured_kwargs = dict(kwargs) + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = CapturingWorkflow() + events = [ + event + async for event in run_workflow_stream( + {"messages": [{"role": "user", "content": "hello"}]}, + cast(Any, workflow), + ) + ] + + event_types = [event.type for event in events] + assert "RUN_STARTED" in event_types + assert workflow.captured_kwargs["stream"] is True + assert "function_invocation_kwargs" not in workflow.captured_kwargs + + +async def test_workflow_run_accepts_camel_case_forwarded_props() -> None: + """forwardedProps (camelCase) is accepted as an alternative key.""" + + class CapturingWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, **kwargs: Any): + self.captured_kwargs = dict(kwargs) + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = CapturingWorkflow() + events = [ + event + async for event in run_workflow_stream( + { + "messages": [{"role": "user", "content": "hello"}], + "forwardedProps": {"source": "frontend"}, + }, + cast(Any, workflow), + ) + ] + + event_types = [event.type for event in events] + assert "RUN_STARTED" in event_types + + assert workflow.captured_kwargs["stream"] is True + assert "function_invocation_kwargs" in workflow.captured_kwargs + assert workflow.captured_kwargs["function_invocation_kwargs"] == { + "forwarded_props": {"source": "frontend"}, + } + + +async def test_workflow_run_passes_empty_dict_forwarded_props() -> None: + """An empty dict forwarded_props={} should still be forwarded (not dropped by truthiness).""" + + class CapturingWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, **kwargs: Any): + self.captured_kwargs = dict(kwargs) + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = CapturingWorkflow() + events = [ + event + async for event in run_workflow_stream( + { + "messages": [{"role": "user", "content": "hello"}], + "forwarded_props": {}, + }, + cast(Any, workflow), + ) + ] + + event_types = [event.type for event in events] + assert "RUN_STARTED" in event_types + assert "RUN_FINISHED" in event_types + + assert workflow.captured_kwargs["stream"] is True + assert "function_invocation_kwargs" in workflow.captured_kwargs + assert workflow.captured_kwargs["function_invocation_kwargs"] == { + "forwarded_props": {}, + } + + +async def test_workflow_run_stream_true_always_passed() -> None: + """stream=True is always passed to workflow.run().""" + + class CapturingWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, **kwargs: Any): + self.captured_kwargs = dict(kwargs) + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = CapturingWorkflow() + _ = [ + event + async for event in run_workflow_stream( + { + "messages": [{"role": "user", "content": "hello"}], + "forwarded_props": {"key": "val"}, + }, + cast(Any, workflow), + ) + ] + + assert workflow.captured_kwargs["stream"] is True + + +async def test_workflow_run_drops_fwd_kwargs_when_run_lacks_param() -> None: + """function_invocation_kwargs is silently dropped if workflow.run() does not accept it.""" + + class StrictWorkflow: + def __init__(self) -> None: + self.captured_kwargs: dict[str, Any] = {} + + def run(self, *, message: Any = None, responses: Any = None, stream: bool = False): + self.captured_kwargs = {"message": message, "responses": responses, "stream": stream} + + async def _stream(): + yield SimpleNamespace(type="started") + + return _stream() + + workflow = StrictWorkflow() + events = [ + event + async for event in run_workflow_stream( + { + "messages": [{"role": "user", "content": "hello"}], + "forwarded_props": {"custom": True}, + }, + cast(Any, workflow), + ) + ] + + event_types = [event.type for event in events] + assert "RUN_STARTED" in event_types + assert "RUN_FINISHED" in event_types + # No TypeError raised, and function_invocation_kwargs was not passed + assert "function_invocation_kwargs" not in workflow.captured_kwargs From ce8b6305d8e7280ac9d22226a17e2e4f0828ef97 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Mon, 20 Apr 2026 22:21:27 -0700 Subject: [PATCH 11/52] Python: Foundry hosted agent V2 (#5379) * Python: Wrapper + Samples 1st (#5177) * Experiment * Update dependency and add non streaming * Add more samples * Rename samples * Add invocations * Comments 1 * Comments 2 * Comments 3 * Improve README * Add local shell sample * WIP: Add eval and memory samples * Update user agent prefix * Update user agent prefix doc * Update dependency (#5215) * Add tests and more content types (#5235) * Add tests * fix tests and sample * Fix formatting * Remove function approval contents * Python: Refine samples and upgrade packages (#5261) * Refine samples and upgrade pacakges * Upgrade to a new package that fixes a bug * Update model env var * Move samples (#5281) * Python: Upgrade agentserver packages (#5284) * Upgrade agentserver packages * Fix new types * Python: Add special handling for workflows (#5298) * Add special handling for workflows * Address comments * Improve samples (#5372) * Python: Add more types (#5378) * Add more type supports * Upgrade packages * Remove TODOs in README * Fix README * Comments and mypy * User agent scoped * Fix README * Fix pre commit * Fix pre commit 2 * Fix pre commit 3 * Fix pre commit 4 * Fix pre commit 5 * Fix pre commit 6 * Add azure-monitor-opentelemetry to dev deps Fixes Samples & Markdown CI failure. The PR's new transitive dep on azure-monitor-opentelemetry-exporter (via azure-ai-agentserver-core) makes pyright resolve the azure.monitor.opentelemetry namespace, flipping the check_md_code_blocks diagnostic for `configure_azure_monitor` from reportMissingImports (filtered) to reportAttributeAccessIssue (not filtered). Installing the umbrella azure-monitor-opentelemetry package in dev makes pyright resolve the symbol correctly, matching the install guidance the observability README already gives users. --------- Co-authored-by: Evan Mattson --- python/.cspell.json | 1 + .../core/agent_framework/_telemetry.py | 41 +- .../core/tests/core/test_telemetry.py | 54 + python/packages/foundry_hosting/LICENSE | 21 + python/packages/foundry_hosting/README.md | 3 + .../__init__.py | 13 + .../_invocations.py | 80 ++ .../_responses.py | 983 ++++++++++++++++++ .../packages/foundry_hosting/pyproject.toml | 99 ++ .../foundry_hosting/tests/test_responses.py | 917 ++++++++++++++++ python/pyproject.toml | 2 + .../samples/02-agents/observability/README.md | 37 +- .../foundry-hosted-agents/README.md | 56 + .../invocations/01_basic/.dockerignore | 6 + .../invocations/01_basic/.env.example | 2 + .../invocations/01_basic}/Dockerfile | 0 .../invocations/01_basic/README.md | 44 + .../invocations/01_basic/agent.manifest.yaml | 23 + .../invocations/01_basic/agent.yaml | 9 + .../invocations/01_basic/main.py | 36 + .../invocations/01_basic/requirements.txt | 2 + .../invocations/02_break_glass/.dockerignore | 6 + .../invocations/02_break_glass/.env.example | 2 + .../invocations/02_break_glass}/Dockerfile | 0 .../invocations/02_break_glass/README.md | 46 + .../02_break_glass/agent.manifest.yaml | 23 + .../invocations/02_break_glass/agent.yaml | 9 + .../invocations/02_break_glass/main.py | 74 ++ .../02_break_glass/requirements.txt | 2 + .../invocations/README.md | 8 + .../responses/01_basic/.dockerignore | 6 + .../responses/01_basic/.env.example | 2 + .../responses/01_basic}/Dockerfile | 0 .../responses/01_basic/README.md | 31 + .../responses/01_basic/agent.manifest.yaml | 23 + .../responses/01_basic/agent.yaml | 8 + .../responses/01_basic/main.py | 36 + .../responses/01_basic/requirements.txt | 2 + .../responses/02_local_tools/.dockerignore | 6 + .../responses/02_local_tools/.env.example | 2 + .../responses/02_local_tools}/Dockerfile | 10 +- .../responses/02_local_tools/README.md | 27 + .../02_local_tools/agent.manifest.yaml | 23 + .../responses/02_local_tools/agent.yaml | 8 + .../responses/02_local_tools/main.py | 74 ++ .../responses/02_local_tools/requirements.txt | 2 + .../responses/03_remote_mcp/.dockerignore | 6 + .../responses/03_remote_mcp/.env.example | 4 + .../responses/03_remote_mcp}/Dockerfile | 10 +- .../responses/03_remote_mcp/README.md | 25 + .../03_remote_mcp/agent.manifest.yaml | 27 + .../responses/03_remote_mcp/agent.yaml | 8 + .../responses/03_remote_mcp/main.py | 76 ++ .../responses/03_remote_mcp/requirements.txt | 2 + .../responses/04_workflows/.dockerignore | 6 + .../responses/04_workflows/.env.example | 2 + .../responses/04_workflows/Dockerfile | 16 + .../responses/04_workflows/README.md | 23 + .../04_workflows/agent.manifest.yaml | 23 + .../responses/04_workflows/agent.yaml | 8 + .../responses/04_workflows/main.py | 70 ++ .../responses/04_workflows/requirements.txt | 2 + .../foundry-hosted-agents/responses/README.md | 11 + .../responses/using_deployed_agent.py | 50 + .../05-end-to-end/hosted_agents/README.md | 145 --- .../agent_with_hosted_mcp/agent.yaml | 30 - .../agent_with_hosted_mcp/main.py | 34 - .../agent_with_hosted_mcp/requirements.txt | 2 - .../agent_with_local_tools/.dockerignore | 66 -- .../agent_with_local_tools/.env.sample | 3 - .../agent_with_local_tools/README.md | 162 --- .../agent_with_local_tools/agent.yaml | 27 - .../agent_with_local_tools/main.py | 144 --- .../agent_with_local_tools/requirements.txt | 2 - .../agent_with_text_search_rag/agent.yaml | 33 - .../agent_with_text_search_rag/main.py | 123 --- .../requirements.txt | 2 - .../agents_in_workflow/agent.yaml | 28 - .../hosted_agents/agents_in_workflow/main.py | 52 - .../agents_in_workflow/requirements.txt | 2 - .../.dockerignore | 51 - .../.env.sample | 3 - .../README.md | 157 --- .../agent.yaml | 24 - .../main.py | 71 -- .../requirements.txt | 2 - python/uv.lock | 403 +++++++ 87 files changed, 3597 insertions(+), 1197 deletions(-) create mode 100644 python/packages/foundry_hosting/LICENSE create mode 100644 python/packages/foundry_hosting/README.md create mode 100644 python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py create mode 100644 python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py create mode 100644 python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py create mode 100644 python/packages/foundry_hosting/pyproject.toml create mode 100644 python/packages/foundry_hosting/tests/test_responses.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.env.example rename python/samples/{05-end-to-end/hosted_agents/agent_with_hosted_mcp => 04-hosting/foundry-hosted-agents/invocations/01_basic}/Dockerfile (100%) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.env.example rename python/samples/{05-end-to-end/hosted_agents/agent_with_text_search_rag => 04-hosting/foundry-hosted-agents/invocations/02_break_glass}/Dockerfile (100%) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/invocations/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.env.example rename python/samples/{05-end-to-end/hosted_agents/agents_in_workflow => 04-hosting/foundry-hosted-agents/responses/01_basic}/Dockerfile (100%) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.env.example rename python/samples/{05-end-to-end/hosted_agents/agent_with_local_tools => 04-hosting/foundry-hosted-agents/responses/02_local_tools}/Dockerfile (50%) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.env.example rename python/samples/{05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow => 04-hosting/foundry-hosted-agents/responses/03_remote_mcp}/Dockerfile (50%) create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.dockerignore create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.env.example create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/Dockerfile create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.manifest.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.yaml create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/main.py create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/requirements.txt create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/README.md create mode 100644 python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/README.md delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt delete mode 100644 python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml delete mode 100644 python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py delete mode 100644 python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt diff --git a/python/.cspell.json b/python/.cspell.json index b72fa96cf5..d53d710352 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -24,6 +24,7 @@ ], "words": [ "aeiou", + "agentserver", "agui", "aiplatform", "azuredocindex", diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index a044fc9d02..a3b0f74146 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -4,6 +4,9 @@ from __future__ import annotations import logging import os +from collections.abc import Generator +from contextlib import contextmanager +from contextvars import ContextVar from typing import Any, Final from . import __version__ as version_info @@ -26,6 +29,35 @@ USER_AGENT_KEY: Final[str] = "User-Agent" HTTP_USER_AGENT: Final[str] = "agent-framework-python" AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type] +_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=()) + + +@contextmanager +def user_agent_prefix(prefix: str) -> Generator[None]: + """Context manager that adds a prefix to the user agent string for the current scope. + + This is useful for upstream layers that want to identify themselves in telemetry + for the duration of a request without permanently mutating global state. + + Args: + prefix: The prefix to add (e.g. "foundry-hosting"). + """ + current = _user_agent_prefixes.get() + token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None + try: + yield + finally: + if token is not None: + _user_agent_prefixes.reset(token) + + +def _get_user_agent() -> str: + """Return the full user agent string including any context-scoped prefixes.""" + prefixes = _user_agent_prefixes.get() + if not prefixes: + return AGENT_FRAMEWORK_USER_AGENT + return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}" + def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]: """Prepend "agent-framework" to the User-Agent in the headers. @@ -57,12 +89,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) """ if not IS_TELEMETRY_ENABLED: return headers or {} + user_agent = _get_user_agent() if not headers: - return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT} - headers[USER_AGENT_KEY] = ( - f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}" - if USER_AGENT_KEY in headers - else AGENT_FRAMEWORK_USER_AGENT - ) + return {USER_AGENT_KEY: user_agent} + headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent return headers diff --git a/python/packages/core/tests/core/test_telemetry.py b/python/packages/core/tests/core/test_telemetry.py index f2d9392b2d..1ba1df1dde 100644 --- a/python/packages/core/tests/core/test_telemetry.py +++ b/python/packages/core/tests/core/test_telemetry.py @@ -8,6 +8,7 @@ from agent_framework import ( USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, prepend_agent_framework_to_user_agent, ) +from agent_framework._telemetry import user_agent_prefix # region Test constants @@ -96,3 +97,56 @@ def test_modifies_original_dict(): assert result is headers # Same object assert "User-Agent" in headers + + +# region Test user_agent_prefix context manager + + +def test_user_agent_prefix_adds_prefix(): + """Test that the context manager adds a prefix within its scope.""" + with user_agent_prefix("test-host"): + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"].startswith("test-host/") + assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"] + + # Prefix is removed after exiting the context + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_user_agent_prefix_ignores_duplicates(): + """Test that duplicate prefixes are not added within nested scopes.""" + with user_agent_prefix("test-host"), user_agent_prefix("test-host"): + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"].count("test-host") == 1 + + +def test_user_agent_prefix_ignores_empty(): + """Test that empty strings are not added as prefixes.""" + with user_agent_prefix(""): + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_user_agent_prefix_restores_on_exit(): + """Test that prefixes are fully restored after the context manager exits.""" + with user_agent_prefix("test-host"): + pass + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + + +def test_user_agent_prefix_nesting(): + """Test that nested context managers compose prefixes correctly.""" + with user_agent_prefix("outer"): + with user_agent_prefix("inner"): + result = prepend_agent_framework_to_user_agent() + assert "outer" in result["User-Agent"] + assert "inner" in result["User-Agent"] + # Inner prefix removed + result = prepend_agent_framework_to_user_agent() + assert "outer" in result["User-Agent"] + assert "inner" not in result["User-Agent"] + # Both removed + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT diff --git a/python/packages/foundry_hosting/LICENSE b/python/packages/foundry_hosting/LICENSE new file mode 100644 index 0000000000..9e841e7a26 --- /dev/null +++ b/python/packages/foundry_hosting/LICENSE @@ -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 diff --git a/python/packages/foundry_hosting/README.md b/python/packages/foundry_hosting/README.md new file mode 100644 index 0000000000..c0794fd4b0 --- /dev/null +++ b/python/packages/foundry_hosting/README.md @@ -0,0 +1,3 @@ +# Foundry Hosting + +This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure. diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py new file mode 100644 index 0000000000..81e8430783 --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +import importlib.metadata + +from ._invocations import InvocationsHostServer +from ._responses import ResponsesHostServer + +try: + __version__ = importlib.metadata.version(__name__) +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" + +__all__ = ["InvocationsHostServer", "ResponsesHostServer"] diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py new file mode 100644 index 0000000000..b5bf98291b --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -0,0 +1,80 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework import AgentSession, BaseAgent, SupportsAgentRun +from agent_framework._telemetry import user_agent_prefix +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from typing_extensions import Any, AsyncGenerator + + +class InvocationsHostServer(InvocationAgentServerHost): + """An invocations server host for an agent.""" + + USER_AGENT_PREFIX = "foundry-hosting" + + def __init__( + self, + agent: BaseAgent, + *, + openapi_spec: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + """Initialize an InvocationsHostServer. + + Args: + agent: The agent to handle responses for. + openapi_spec: The OpenAPI specification for the server. + **kwargs: Additional keyword arguments. + + This host will expect the request to be a JSON body with a "message" field. + The response from the host will be a JSON object with a "response" field containing + the agent's response and a "session_id" field containing the session ID. + """ + super().__init__(openapi_spec=openapi_spec, **kwargs) + + if not isinstance(agent, SupportsAgentRun): + raise TypeError("Agent must support the SupportsAgentRun interface") + + self._agent = agent + self._sessions: dict[str, AgentSession] = {} + self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType] + + async def _handle_invoke(self, request: Request) -> Response: + """Invoke the agent with the given request.""" + with user_agent_prefix(self.USER_AGENT_PREFIX): + return await self._handle_invoke_inner(request) + + async def _handle_invoke_inner(self, request: Request) -> Response: + """Core invoke handler logic.""" + data = await request.json() + session_id: str = request.state.session_id + + stream = data.get("stream", False) + user_message = data.get("message", None) + if user_message is None: + error = "Missing 'message' in request" + if stream: + return StreamingResponse(content=error, status_code=400) + return Response(content=error, status_code=400) + + session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id)) + + if stream: + + async def stream_response() -> AsyncGenerator[str]: + async for update in self._agent.run(user_message, session=session, stream=True): + if update.text: + yield update.text + + return StreamingResponse( + stream_response(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + response = await self._agent.run([user_message], session=session, stream=stream) + return JSONResponse({ + "response": response.text, + "session_id": session_id, + }) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py new file mode 100644 index 0000000000..cdd4b34b4f --- /dev/null +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -0,0 +1,983 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import asyncio +import json +import logging +import os +from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence +from typing import cast + +from agent_framework import ( + ChatOptions, + Content, + ContextProvider, + FileCheckpointStorage, + HistoryProvider, + Message, + RawAgent, + SupportsAgentRun, + WorkflowAgent, +) +from agent_framework._telemetry import user_agent_prefix +from azure.ai.agentserver.responses import ( + ResponseContext, + ResponseEventStream, + ResponseProviderProtocol, + ResponsesServerOptions, +) +from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost +from azure.ai.agentserver.responses.models import ( + ComputerScreenshotContent, + CreateResponse, + FunctionCallOutputItemParam, + FunctionShellAction, + FunctionShellCallOutputContent, + FunctionShellCallOutputExitOutcome, + LocalEnvironmentResource, + MessageContent, + MessageContentInputFileContent, + MessageContentInputImageContent, + MessageContentInputTextContent, + MessageContentOutputTextContent, + MessageContentReasoningTextContent, + MessageContentRefusalContent, + OAuthConsentRequestOutputItem, + OutputItem, + OutputItemApplyPatchToolCall, + OutputItemApplyPatchToolCallOutput, + OutputItemCodeInterpreterToolCall, + OutputItemComputerToolCall, + OutputItemComputerToolCallOutputResource, + OutputItemCustomToolCall, + OutputItemCustomToolCallOutput, + OutputItemFileSearchToolCall, + OutputItemFunctionShellCall, + OutputItemFunctionShellCallOutput, + OutputItemFunctionToolCall, + OutputItemImageGenToolCall, + OutputItemLocalShellToolCall, + OutputItemLocalShellToolCallOutput, + OutputItemMcpApprovalRequest, + OutputItemMcpApprovalResponseResource, + OutputItemMcpToolCall, + OutputItemMessage, + OutputItemOutputMessage, + OutputItemReasoningItem, + OutputItemWebSearchToolCall, + OutputMessageContent, + OutputMessageContentOutputTextContent, + OutputMessageContentRefusalContent, + ResponseStreamEvent, + StructuredOutputsOutputItem, + SummaryTextContent, + TextContent, +) +from azure.ai.agentserver.responses.streaming._builders import ( + OutputItemFunctionCallBuilder, + OutputItemMcpCallBuilder, + OutputItemMessageBuilder, + OutputItemReasoningItemBuilder, + ReasoningSummaryPartBuilder, + TextContentBuilder, +) +from typing_extensions import Any + +logger = logging.getLogger(__name__) + + +class ResponsesHostServer(ResponsesAgentServerHost): + """A responses server host for an agent.""" + + USER_AGENT_PREFIX = "foundry-hosting" + # TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally + CHECKPOINT_STORAGE_PATH = "/.checkpoints" + + def __init__( + self, + agent: SupportsAgentRun, + *, + prefix: str = "", + options: ResponsesServerOptions | None = None, + store: ResponseProviderProtocol | None = None, + **kwargs: Any, + ) -> None: + """Initialize a ResponsesHostServer. + + Args: + agent: The agent to handle responses for. + prefix: The URL prefix for the server. + options: Optional server options. + store: Optional response store. + **kwargs: Additional keyword arguments. + + Note: + 1. The agent must not have a history provider with `load_messages=True`, + because history is managed by the hosting infrastructure. + 2. The agent must not have any context providers that maintain context + in memory, because the hosting environment may get deactivated between + requests, and any in-memory context would be lost. + """ + super().__init__(prefix=prefix, options=options, store=store, **kwargs) + + for provider in getattr(agent, "context_providers", []): + if isinstance(provider, HistoryProvider) and provider.load_messages: + raise RuntimeError( + "There shouldn't be a history provider with `load_messages=True` already present. " + "History is managed by the hosting infrastructure." + ) + provider = cast(ContextProvider, provider) + logger.warning( + "Context provider %s is present. If it maintains context in memory, " + "the context may be lost between requests. Use with caution.", + provider.source_id, + ) + + self._is_workflow_agent = False + self._checkpoint_storage_path = None + if isinstance(agent, WorkflowAgent): + if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage] + raise RuntimeError( + "There should not be a checkpoint storage already present in the workflow agent. " + "The hosting infrastructure will manage checkpoints instead." + ) + self._checkpoint_storage_path = ( + self.CHECKPOINT_STORAGE_PATH + if self.config.is_hosted + else os.path.join(os.getcwd(), self.CHECKPOINT_STORAGE_PATH.lstrip("/")) + ) + self._is_workflow_agent = True + + self._agent = agent + self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType] + + @staticmethod + def _is_streaming_request(request: CreateResponse) -> bool: + """Check if the request is a streaming request.""" + return request.stream is not None and request.stream is True + + async def _handler( + self, + request: CreateResponse, + context: ResponseContext, + cancellation_signal: asyncio.Event, + ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: + """Handle the creation of a response.""" + with user_agent_prefix(self.USER_AGENT_PREFIX): + async for event in self._handle_inner(request, context, cancellation_signal): + yield event + + async def _handle_inner( + self, + request: CreateResponse, + context: ResponseContext, + cancellation_signal: asyncio.Event, + ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: + """Core handler logic.""" + if self._is_workflow_agent: + # Workflow agents are handled differently because they require checkpoint restoration + async for event in self._handle_workflow_agent(request, context, cancellation_signal): + yield event + return + + input_text = await context.get_input_text() + history = await context.get_history() + messages: list[str | Content | Message] = [*_to_messages(history), input_text] + + chat_options, are_options_set = _to_chat_options(request) + + is_streaming_request = self._is_streaming_request(request) + response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) + + yield response_event_stream.emit_created() + yield response_event_stream.emit_in_progress() + + if not is_streaming_request: + # Run the agent in non-streaming mode + if isinstance(self._agent, RawAgent): + raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] + response = await raw_agent.run(messages, stream=False, options=chat_options) + else: + if are_options_set: + logger.warning("Agent doesn't support runtime options. They will be ignored.") + response = await self._agent.run(messages, stream=False) + + for message in response.messages: + for content in message.contents: + async for item in _to_outputs(response_event_stream, content): + yield item + + yield response_event_stream.emit_completed() + return + + # Run the agent in streaming mode + if isinstance(self._agent, RawAgent): + raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] + response_stream = raw_agent.run(messages, stream=True, options=chat_options) + else: + if are_options_set: + logger.warning("Agent doesn't support runtime options. They will be ignored.") + response_stream = self._agent.run(messages, stream=True) + + # Track the current active output item builder for streaming; + # lazily created on matching content, closed when a different type arrives. + tracker = _OutputItemTracker(response_event_stream) + + async for update in response_stream: + for content in update.contents: + for event in tracker.handle(content): + yield event + if tracker.needs_async: + async for item in _to_outputs(response_event_stream, content): + yield item + tracker.needs_async = False + + # Close any remaining active builder + for event in tracker.close(): + yield event + + yield response_event_stream.emit_completed() + + async def _handle_workflow_agent( + self, + request: CreateResponse, + context: ResponseContext, + cancellation_signal: asyncio.Event, + ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: + """Handle the creation of a response for a workflow agent. + + Why this is required: + The sandbox may be deactivated after some period of inactivity, and only data managed + by the hosting infrastructure or files will be preserved upon deactivation. + """ + input_text = await context.get_input_text() + is_streaming_request = self._is_streaming_request(request) + + _, are_options_set = _to_chat_options(request) + if are_options_set: + logger.warning("Workflow agent doesn't support runtime options. They will be ignored.") + + if request.previous_response_id is not None and context.conversation_id is not None: + raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.") + context_id = request.previous_response_id or context.conversation_id + + # The following should never happen due to the checks above. + # This is for type safety and defensive programming. + if self._checkpoint_storage_path is None: + raise RuntimeError("Checkpoint storage path is not configured for workflow agent.") + if not isinstance(self._agent, WorkflowAgent): + raise RuntimeError("Agent is not a workflow agent.") + + # Restore from the latest checkpoint if available, otherwise start with an empty history + if context_id is not None: + checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id)) + latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name) + if latest_checkpoint is not None: + if not is_streaming_request: + _ = await self._agent.run( + stream=False, + checkpoint_id=latest_checkpoint.checkpoint_id, + checkpoint_storage=checkpoint_storage, + ) + else: + # Consume the streaming or the invocation will result in a no-op + async for _ in self._agent.run( + stream=True, + checkpoint_id=latest_checkpoint.checkpoint_id, + checkpoint_storage=checkpoint_storage, + ): + pass + + # Now run the agent with the latest input + response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) + + # Create a new checkpoint storage for this response based on the following rules: + # - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response + # - If a previous response ID is provided, create a new checkpoint storage for this response + # - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation + context_id = context.conversation_id or context.response_id + checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id)) + + yield response_event_stream.emit_created() + yield response_event_stream.emit_in_progress() + + if not is_streaming_request: + # Run the agent in non-streaming mode + response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage) + + for message in response.messages: + for content in message.contents: + async for item in _to_outputs(response_event_stream, content): + yield item + + await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name) + yield response_event_stream.emit_completed() + return + + # Run the agent in streaming mode + response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage) + + # Track the current active output item builder for streaming; + # lazily created on matching content, closed when a different type arrives. + tracker = _OutputItemTracker(response_event_stream) + + async for update in response_stream: + for content in update.contents: + for event in tracker.handle(content): + yield event + if tracker.needs_async: + async for item in _to_outputs(response_event_stream, content): + yield item + tracker.needs_async = False + + # Close any remaining active builder + for event in tracker.close(): + yield event + + await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name) + yield response_event_stream.emit_completed() + return + + @staticmethod + async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None: + """Delete all checkpoints except the latest one. + + We only need the last checkpoint for each invocation. + """ + latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name) + if latest_checkpoint is not None: + all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name) + for checkpoint in all_checkpoints: + if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id: + await checkpoint_storage.delete(checkpoint.checkpoint_id) + + +# region Active Builder State + + +class _OutputItemTracker: + """Tracks the current active output item builder during streaming. + + Handles lazy creation, delta emission, and closing of streaming builders + for text messages, reasoning, function calls, and MCP calls. + """ + + _DELTA_TYPES = frozenset({"text", "text_reasoning", "function_call", "mcp_server_tool_call"}) + + def __init__(self, stream: ResponseEventStream) -> None: + self._stream = stream + self._active_type: str | None = None + self._active_id: str | None = None + # Accumulated delta text for the current active builder + self._accumulated: list[str] = [] + # Builder state — only one is active at a time + self._message_item: OutputItemMessageBuilder | None = None + self._text_content: TextContentBuilder | None = None + self._reasoning_item: OutputItemReasoningItemBuilder | None = None + self._summary_part: ReasoningSummaryPartBuilder | None = None + self._fc_builder: OutputItemFunctionCallBuilder | None = None + self._mcp_builder: OutputItemMcpCallBuilder | None = None + self.needs_async = False + + def handle(self, content: Content) -> Generator[ResponseStreamEvent]: + """Process a content item, yielding sync events. + + Sets ``needs_async = True`` if the caller must also drain an + async ``_to_outputs`` call for this content. + """ + if content.type == "text" and content.text is not None: + if self._active_type != "text": + yield from self._close() + yield from self._open_message() + self._accumulated.append(content.text) + if self._text_content is not None: + yield self._text_content.emit_delta(content.text) + + elif content.type == "text_reasoning" and content.text is not None: + if self._active_type != "text_reasoning": + yield from self._close() + yield from self._open_reasoning() + self._accumulated.append(content.text) + if self._summary_part is not None: + yield self._summary_part.emit_text_delta(content.text) + + elif content.type == "function_call" and content.call_id is not None: + if self._active_type != "function_call" or self._active_id != content.call_id: + yield from self._close() + yield from self._open_function_call(content) + args_str = _arguments_to_str(content.arguments) + self._accumulated.append(args_str) + if self._fc_builder is not None: + yield self._fc_builder.emit_arguments_delta(args_str) + + elif content.type == "mcp_server_tool_call" and content.tool_name: + key = f"{content.server_name or 'default'}::{content.tool_name}" + if self._active_type != "mcp_server_tool_call" or self._active_id != key: + yield from self._close() + yield from self._open_mcp_call(content) + args_str = _arguments_to_str(content.arguments) + self._accumulated.append(args_str) + if self._mcp_builder is not None: + yield self._mcp_builder.emit_arguments_delta(args_str) + + else: + yield from self._close() + self.needs_async = True + + def close(self) -> Generator[ResponseStreamEvent]: + """Close any remaining active builder.""" + yield from self._close() + + # -- Private open/close helpers -- + + def _open_message(self) -> Generator[ResponseStreamEvent]: + self._message_item = self._stream.add_output_item_message() + self._text_content = self._message_item.add_text_content() + self._active_type = "text" + self._active_id = None + yield self._message_item.emit_added() + yield self._text_content.emit_added() + + def _open_reasoning(self) -> Generator[ResponseStreamEvent]: + self._reasoning_item = self._stream.add_output_item_reasoning_item() + self._summary_part = self._reasoning_item.add_summary_part() + self._active_type = "text_reasoning" + self._active_id = None + yield self._reasoning_item.emit_added() + yield self._summary_part.emit_added() + + def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent]: + self._fc_builder = self._stream.add_output_item_function_call( + name=content.name or "", + call_id=content.call_id or "", + ) + self._active_type = "function_call" + self._active_id = content.call_id + yield self._fc_builder.emit_added() + + def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]: + self._mcp_builder = self._stream.add_output_item_mcp_call( + server_label=content.server_name or "default", + name=content.tool_name or "", + ) + self._active_type = "mcp_server_tool_call" + self._active_id = f"{content.server_name or 'default'}::{content.tool_name}" + yield self._mcp_builder.emit_added() + + def _close(self) -> Generator[ResponseStreamEvent]: + accumulated = "".join(self._accumulated) + + if self._active_type == "text" and self._text_content and self._message_item: + yield self._text_content.emit_text_done(accumulated) + yield self._text_content.emit_done() + yield self._message_item.emit_done() + self._text_content = None + self._message_item = None + + elif self._active_type == "text_reasoning" and self._summary_part and self._reasoning_item: + yield self._summary_part.emit_text_done(accumulated) + yield self._summary_part.emit_done() + yield self._reasoning_item.emit_done() + self._summary_part = None + self._reasoning_item = None + + elif self._active_type == "function_call" and self._fc_builder: + yield self._fc_builder.emit_arguments_done(accumulated) + yield self._fc_builder.emit_done() + self._fc_builder = None + + elif self._active_type == "mcp_server_tool_call" and self._mcp_builder: + yield self._mcp_builder.emit_arguments_done(accumulated) + yield self._mcp_builder.emit_completed() + yield self._mcp_builder.emit_done() + self._mcp_builder = None + + self._active_type = None + self._active_id = None + self._accumulated.clear() + + +# endregion + + +# region Option Conversion + + +def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]: + """Converts a CreateResponse request to ChatOptions. + + Args: + request (CreateResponse): The request to convert. + + Returns: + ChatOptions: The converted ChatOptions. + bool: Whether any options were set. + + """ + chat_options = ChatOptions() + are_options_set = False + + if request.temperature is not None: + chat_options["temperature"] = request.temperature + are_options_set = True + if request.top_p is not None: + chat_options["top_p"] = request.top_p + are_options_set = True + if request.max_output_tokens is not None: + chat_options["max_tokens"] = request.max_output_tokens + are_options_set = True + if request.parallel_tool_calls is not None: + chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls + are_options_set = True + + return chat_options, are_options_set + + +# endregion + + +# region Input Message Conversion + + +def _to_messages(history: Sequence[OutputItem]) -> list[Message]: + """Converts a sequence of OutputItem objects to a list of Message objects. + + Args: + history (Sequence[OutputItem]): The sequence of OutputItem objects to convert. + + Returns: + list[Message]: The list of Message objects. + """ + messages: list[Message] = [] + for item in history: + messages.append(_to_message(item)) + return messages + + +def _to_message(item: OutputItem) -> Message: + """Converts an OutputItem to a Message. + + Args: + item (OutputItem): The OutputItem to convert. + + Returns: + Message: The converted Message. + + Raises: + ValueError: If the OutputItem type is not supported. + """ + if item.type == "output_message": + output_msg = cast(OutputItemOutputMessage, item) + return Message( + role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content] + ) + + if item.type == "message": + msg = cast(OutputItemMessage, item) + return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + + if item.type == "function_call": + fc = cast(OutputItemFunctionToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + ) + + if item.type == "function_call_output": + fco = cast(FunctionCallOutputItemParam, item) + output = fco.output if isinstance(fco.output, str) else str(fco.output) + return Message( + role="tool", + contents=[Content.from_function_result(fco.call_id, result=output)], + ) + + if item.type == "reasoning": + reasoning = cast(OutputItemReasoningItem, item) + contents: list[Content] = [] + if reasoning.summary: + for summary in reasoning.summary: + contents.append(Content.from_text(summary.text)) + return Message(role="assistant", contents=contents) + + if item.type == "mcp_call": + mcp = cast(OutputItemMcpToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ], + ) + + if item.type == "mcp_approval_request": + mcp_req = cast(OutputItemMcpApprovalRequest, item) + mcp_call_content = Content.from_mcp_server_tool_call( + mcp_req.id, + mcp_req.name, + server_name=mcp_req.server_label, + arguments=mcp_req.arguments, + ) + return Message( + role="assistant", + contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)], + ) + + if item.type == "mcp_approval_response": + mcp_resp = cast(OutputItemMcpApprovalResponseResource, item) + # Build a placeholder function_call Content since the original call details are not available + placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval") + return Message( + role="user", + contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)], + ) + + if item.type == "code_interpreter_call": + ci = cast(OutputItemCodeInterpreterToolCall, item) + return Message( + role="assistant", + contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)], + ) + + if item.type == "image_generation_call": + ig = cast(OutputItemImageGenToolCall, item) + return Message( + role="assistant", + contents=[Content.from_image_generation_tool_call(image_id=ig.id)], + ) + + if item.type == "shell_call": + sc = cast(OutputItemFunctionShellCall, item) + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=sc.call_id, + commands=sc.action.commands, + status=str(sc.status), + ) + ], + ) + + if item.type == "shell_call_output": + sco = cast(OutputItemFunctionShellCallOutput, item) + outputs = [ + Content.from_shell_command_output( + stdout=out.stdout or "", + stderr=out.stderr or "", + exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None, + ) + for out in (sco.output or []) + ] + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=sco.call_id, + outputs=outputs, + max_output_length=sco.max_output_length, + ) + ], + ) + + if item.type == "local_shell_call": + lsc = cast(OutputItemLocalShellToolCall, item) + commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else [] + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=lsc.call_id, + commands=commands, + status=str(lsc.status), + ) + ], + ) + + if item.type == "local_shell_call_output": + lsco = cast(OutputItemLocalShellToolCallOutput, item) + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=lsco.id, + outputs=[Content.from_shell_command_output(stdout=lsco.output)], + ) + ], + ) + + if item.type == "file_search_call": + fs = cast(OutputItemFileSearchToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + fs.id, + "file_search", + arguments=json.dumps({"queries": fs.queries}), + ) + ], + ) + + if item.type == "web_search_call": + ws = cast(OutputItemWebSearchToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ws.id, "web_search")], + ) + + if item.type == "computer_call": + cc = cast(OutputItemComputerToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + cc.call_id, + "computer_use", + arguments=str(cc.action), + ) + ], + ) + + if item.type == "computer_call_output": + cco = cast(OutputItemComputerToolCallOutputResource, item) + return Message( + role="tool", + contents=[Content.from_function_result(cco.call_id, result=str(cco.output))], + ) + + if item.type == "custom_tool_call": + ct = cast(OutputItemCustomToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + ) + + if item.type == "custom_tool_call_output": + cto = cast(OutputItemCustomToolCallOutput, item) + output = cto.output if isinstance(cto.output, str) else str(cto.output) + return Message( + role="tool", + contents=[Content.from_function_result(cto.call_id, result=output)], + ) + + if item.type == "apply_patch_call": + ap = cast(OutputItemApplyPatchToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + ap.call_id, + "apply_patch", + arguments=str(ap.operation), + ) + ], + ) + + if item.type == "apply_patch_call_output": + apo = cast(OutputItemApplyPatchToolCallOutput, item) + return Message( + role="tool", + contents=[Content.from_function_result(apo.call_id, result=apo.output or "")], + ) + + if item.type == "oauth_consent_request": + oauth = cast(OAuthConsentRequestOutputItem, item) + return Message( + role="assistant", + contents=[Content.from_oauth_consent_request(oauth.consent_link)], + ) + + if item.type == "structured_outputs": + so = cast(StructuredOutputsOutputItem, item) + text = json.dumps(so.output) if not isinstance(so.output, str) else so.output + return Message(role="assistant", contents=[Content.from_text(text)]) + + raise ValueError(f"Unsupported OutputItem type: {item.type}") + + +def _convert_output_message_content(content: OutputMessageContent) -> Content: + """Converts an OutputMessageContent to a Content object. + + Args: + content (OutputMessageContent): The OutputMessageContent to convert. + + Returns: + Content: The converted Content object. + + Raises: + ValueError: If the OutputMessageContent type is not supported. + """ + if content.type == "output_text": + text_content = cast(OutputMessageContentOutputTextContent, content) + return Content.from_text(text_content.text) + if content.type == "refusal": + refusal_content = cast(OutputMessageContentRefusalContent, content) + return Content.from_text(refusal_content.refusal) + + raise ValueError(f"Unsupported OutputMessageContent type: {content.type}") + + +def _convert_message_content(content: MessageContent) -> Content: + """Converts a MessageContent to a Content object. + + Args: + content (MessageContent): The MessageContent to convert. + + Returns: + Content: The converted Content object. + + Raises: + ValueError: If the MessageContent type is not supported. + """ + if content.type == "input_text": + input_text = cast(MessageContentInputTextContent, content) + return Content.from_text(input_text.text) + if content.type == "output_text": + output_text = cast(MessageContentOutputTextContent, content) + return Content.from_text(output_text.text) + if content.type == "text": + text = cast(TextContent, content) + return Content.from_text(text.text) + if content.type == "summary_text": + summary = cast(SummaryTextContent, content) + return Content.from_text(summary.text) + if content.type == "refusal": + refusal = cast(MessageContentRefusalContent, content) + return Content.from_text(refusal.refusal) + if content.type == "reasoning_text": + reasoning = cast(MessageContentReasoningTextContent, content) + return Content.from_text_reasoning(text=reasoning.text) + if content.type == "input_image": + image = cast(MessageContentInputImageContent, content) + if image.image_url: + return Content.from_uri(image.image_url) + if image.file_id: + return Content.from_hosted_file(image.file_id) + if content.type == "input_file": + file = cast(MessageContentInputFileContent, content) + if file.file_url: + return Content.from_uri(file.file_url) + if file.file_id: + return Content.from_hosted_file(file.file_id, name=file.filename) + if content.type == "computer_screenshot": + screenshot = cast(ComputerScreenshotContent, content) + return Content.from_uri(screenshot.image_url) + + raise ValueError(f"Unsupported MessageContent type: {content.type}") + + +# endregion + +# region Output Item Conversion + + +def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str: + """Convert arguments to a JSON string. + + Args: + arguments: The arguments to convert, can be a string, mapping, or None. + + Returns: + The arguments as a JSON string. + """ + if arguments is None: + return "" + if isinstance(arguments, str): + return arguments + return json.dumps(arguments) + + +async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]: + """Converts a Content object to an async sequence of ResponseStreamEvent objects. + + Args: + stream: The ResponseEventStream to use for building events. + content: The Content to convert. + + Yields: + ResponseStreamEvent: The converted event objects. + + Raises: + ValueError: If the Content type is not supported. + """ + if content.type == "text" and content.text is not None: + async for event in stream.aoutput_item_message(content.text): + yield event + elif content.type == "text_reasoning" and content.text is not None: + async for event in stream.aoutput_item_reasoning_item(content.text): + yield event + elif content.type == "function_call": + async for event in stream.aoutput_item_function_call( + content.name, # type: ignore[arg-type] + content.call_id, # type: ignore[arg-type] + _arguments_to_str(content.arguments), + ): + yield event + elif content.type == "function_result": + async for event in stream.aoutput_item_function_call_output( + content.call_id, # type: ignore[arg-type] + str(content.result or ""), + ): + yield event + elif content.type == "image_generation_tool_result" and content.outputs is not None: + async for event in stream.aoutput_item_image_gen_call(str(content.outputs)): + yield event + elif content.type == "mcp_server_tool_call": + mcp_call = stream.add_output_item_mcp_call( + server_label=content.server_name or "default", + name=content.tool_name or "", + ) + yield mcp_call.emit_added() + async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)): + yield event + yield mcp_call.emit_completed() + yield mcp_call.emit_done() + elif content.type == "mcp_server_tool_result": + output = ( + content.output + if isinstance(content.output, str) + else str(content.output) + if content.output is not None + else "" + ) + async for event in stream.aoutput_item_custom_tool_call_output(content.call_id or "", output): + yield event + elif content.type == "shell_tool_call": + action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0) + async for event in stream.aoutput_item_function_shell_call( + content.call_id or "", + action, + LocalEnvironmentResource(), + status=content.status or "completed", + ): + yield event + elif content.type == "shell_tool_result": + output_items: list[FunctionShellCallOutputContent] = [] + if content.outputs: + for out in content.outputs: + exit_code = getattr(out, "exit_code", None) + output_items.append( + FunctionShellCallOutputContent( + stdout=getattr(out, "stdout", "") or "", + stderr=getattr(out, "stderr", "") or "", + outcome=FunctionShellCallOutputExitOutcome(exit_code=exit_code if exit_code is not None else 0), + ) + ) + async for event in stream.aoutput_item_function_shell_call_output( + content.call_id or "", + output_items, + status=content.status or "completed", + max_output_length=content.max_output_length, + ): + yield event + else: + # Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream. + logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.") + + +# endregion diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml new file mode 100644 index 0000000000..3078e018d2 --- /dev/null +++ b/python/packages/foundry_hosting/pyproject.toml @@ -0,0 +1,99 @@ +[project] +name = "agent-framework-foundry-hosting" +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.0a260420" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Alpha", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.0.0,<2", + "azure-ai-agentserver-core==2.0.0b2", + "azure-ai-agentserver-responses==1.0.0b4", + "azure-ai-agentserver-invocations==1.0.0b2", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +include = ["agent_framework_foundry_hosting"] +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_foundry_hosting"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" \ No newline at end of file diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py new file mode 100644 index 0000000000..f30d033009 --- /dev/null +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -0,0 +1,917 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""HTTP round-trip tests for ResponsesHostServer. + +These tests exercise the full HTTP pipeline using httpx.AsyncClient with +ASGITransport — no real server process is started. Requests go through +the Starlette routing stack, the Responses API middleware, and arrive at +the registered _handle_create handler. +""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from agent_framework import ( + AgentResponse, + AgentResponseUpdate, + Content, + HistoryProvider, + Message, + RawAgent, + ResponseStream, +) +from azure.ai.agentserver.responses import InMemoryResponseProvider +from typing_extensions import Any + +from agent_framework_foundry_hosting import ResponsesHostServer +from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage] + +# region Helpers + + +def _make_agent( + *, + response: AgentResponse | None = None, + stream_updates: list[AgentResponseUpdate] | None = None, +) -> MagicMock: + """Create a mock agent implementing SupportsAgentRun.""" + agent = MagicMock(spec=RawAgent) + agent.id = "test-agent" + agent.name = "Test Agent" + agent.description = "A mock agent for testing" + agent.context_providers = [] + + if response is not None: + + async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse: + return response + + agent.run = AsyncMock(side_effect=run_non_streaming) + + if stream_updates is not None: + + async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]: + for update in stream_updates: + yield update + + def run_streaming(*args: Any, **kwargs: Any) -> Any: + if kwargs.get("stream"): + return ResponseStream(_stream_gen()) # type: ignore + raise NotImplementedError("Only streaming is configured on this mock") + + agent.run = MagicMock(side_effect=run_streaming) + + return agent + + +def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer: + """Create a ResponsesHostServer with an in-memory store.""" + return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs) + + +async def _post( + server: ResponsesHostServer, + *, + input_text: str = "Hello", + model: str = "test-model", + stream: bool = False, + temperature: float | None = None, + top_p: float | None = None, + max_output_tokens: int | None = None, + parallel_tool_calls: bool | None = None, +) -> httpx.Response: + """Send a POST /responses request through the ASGI transport.""" + payload: dict[str, Any] = {"model": model, "input": input_text, "stream": stream} + if temperature is not None: + payload["temperature"] = temperature + if top_p is not None: + payload["top_p"] = top_p + if max_output_tokens is not None: + payload["max_output_tokens"] = max_output_tokens + if parallel_tool_calls is not None: + payload["parallel_tool_calls"] = parallel_tool_calls + + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/responses", json=payload) + + +def _parse_sse_events(body: str) -> list[dict[str, Any]]: + """Parse SSE text into a list of event dicts with 'event' and 'data' keys.""" + events: list[dict[str, Any]] = [] + current_event: str | None = None + current_data_lines: list[str] = [] + + for line in body.split("\n"): + if line.startswith("event: "): + current_event = line[len("event: ") :] + elif line.startswith("data: "): + current_data_lines.append(line[len("data: ") :]) + elif line.strip() == "" and current_event is not None: + data_str = "\n".join(current_data_lines) + try: + data = json.loads(data_str) + except json.JSONDecodeError: + data = data_str + events.append({"event": current_event, "data": data}) + current_event = None + current_data_lines = [] + + return events + + +def _sse_event_types(events: list[dict[str, Any]]) -> list[str]: + """Extract event type strings from parsed SSE events.""" + return [e["event"] for e in events] + + +# endregion + + +# region Initialization + + +class TestResponsesHostServerInit: + def test_init_basic(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + server = _make_server(agent) + assert server is not None + + def test_init_rejects_history_provider_with_load_messages(self) -> None: + hp = HistoryProvider(source_id="test", load_messages=True) + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + agent.context_providers = [hp] + with pytest.raises(RuntimeError, match="history provider"): + ResponsesHostServer(agent) + + +# endregion + + +# region Health Check + + +class TestHealthCheck: + async def test_readiness(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])]) + ) + server = _make_server(agent) + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.get("/readiness") + assert resp.status_code == 200 + + +# endregion + + +# region Non-streaming + + +class TestNonStreaming: + async def test_basic_text_response(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello!")])]) + ) + server = _make_server(agent) + resp = await _post(server, input_text="Hi", stream=False) + + assert resp.status_code == 200 + assert "application/json" in resp.headers["content-type"] + + body = resp.json() + assert body["object"] == "response" + assert body["status"] == "completed" + assert len(body["output"]) > 0 + + # Find the message output item with our text + text_found = False + for item in body["output"]: + assert item["type"] == "message" + for part in item.get("content", []): + if part.get("type") == "output_text" and part.get("text") == "Hello!": + text_found = True + assert text_found, f"Expected 'Hello!' in output, got: {body['output']}" + + async def test_function_call_and_result(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_weather", arguments='{"loc": "NYC"}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="sunny")]), + Message(role="assistant", contents=[Content.from_text("The weather is sunny!")]), + ] + ) + ) + server = _make_server(agent) + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + types = [item["type"] for item in body["output"]] + assert "function_call" in types + assert "function_call_output" in types + assert "message" in types + + async def test_reasoning_content(self) -> None: + agent = _make_agent( + response=AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning(text="Let me think..."), + Content.from_text("The answer is 42"), + ], + ), + ] + ) + ) + server = _make_server(agent) + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + types = [item["type"] for item in body["output"]] + assert "reasoning" in types + assert "message" in types + + async def test_empty_response(self) -> None: + agent = _make_agent(response=AgentResponse(messages=[])) + server = _make_server(agent) + resp = await _post(server, stream=False) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + async def test_chat_options_forwarded(self) -> None: + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]) + ) + server = _make_server(agent) + resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024) + + assert resp.status_code == 200 + agent.run.assert_awaited_once() + call_kwargs = agent.run.call_args.kwargs + assert call_kwargs["stream"] is False + options = call_kwargs["options"] + assert options["temperature"] == 0.5 + assert options["top_p"] == 0.9 + assert options["max_tokens"] == 1024 + + +# endregion + + +# region Streaming + + +class TestStreaming: + async def test_basic_text_streaming(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("world!")], role="assistant"), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[0] == "response.created" + assert types[1] == "response.in_progress" + assert types[-1] == "response.completed" + assert "response.output_text.delta" in types + assert types.count("response.output_text.delta") == 2 + assert "response.output_text.done" in types + + # Verify the accumulated text in the done event + done_events = [e for e in events if e["event"] == "response.output_text.done"] + assert len(done_events) == 1 + assert done_events[0]["data"]["text"] == "Hello world!" + + async def test_function_call_streaming(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments='{"q":')], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments=' "hello"}')], + role="assistant", + ), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[0] == "response.created" + assert types[-1] == "response.completed" + assert types.count("response.function_call_arguments.delta") == 2 + assert "response.function_call_arguments.done" in types + + # Verify accumulated arguments + args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"] + assert len(args_done) == 1 + assert args_done[0]["data"]["arguments"] == '{"q": "hello"}' + + async def test_alternating_text_and_function_call(self) -> None: + agent = _make_agent( + stream_updates=[ + # Text deltas + AgentResponseUpdate(contents=[Content.from_text("Let me ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("search...")], role="assistant"), + # Function call argument deltas + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments='{"q":')], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "search", arguments=' "x"}')], + role="assistant", + ), + # More text deltas + AgentResponseUpdate(contents=[Content.from_text("Found ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("it!")], role="assistant"), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[0] == "response.created" + assert types[-1] == "response.completed" + + # 4 text deltas + 2 function call argument deltas + assert types.count("response.output_text.delta") == 4 + assert types.count("response.function_call_arguments.delta") == 2 + + # 3 distinct output items (text, fc, text) + assert types.count("response.output_item.added") == 3 + assert types.count("response.output_item.done") == 3 + + # Verify accumulated content + text_done = [e for e in events if e["event"] == "response.output_text.done"] + assert len(text_done) == 2 + assert text_done[0]["data"]["text"] == "Let me search..." + assert text_done[1]["data"]["text"] == "Found it!" + + args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"] + assert len(args_done) == 1 + assert args_done[0]["data"]["arguments"] == '{"q": "x"}' + + async def test_reasoning_then_text_streaming(self) -> None: + agent = _make_agent( + stream_updates=[ + # Reasoning deltas + AgentResponseUpdate(contents=[Content.from_text_reasoning(text="Let me ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text_reasoning(text="think...")], role="assistant"), + # Text deltas + AgentResponseUpdate(contents=[Content.from_text("The answer ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("is 42")], role="assistant"), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[0] == "response.created" + assert types[-1] == "response.completed" + # Reasoning + text = 2 output items + assert types.count("response.output_item.added") == 2 + assert types.count("response.output_item.done") == 2 + assert types.count("response.output_text.delta") == 2 + + # Verify accumulated text + text_done = [e for e in events if e["event"] == "response.output_text.done"] + assert len(text_done) == 1 + assert text_done[0]["data"]["text"] == "The answer is 42" + + async def test_empty_streaming(self) -> None: + agent = _make_agent(stream_updates=[]) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types == ["response.created", "response.in_progress", "response.completed"] + + async def test_mixed_contents_in_single_update(self) -> None: + """Text and function call in one update switches builder mid-update.""" + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content.from_text("Let me search"), + Content.from_function_call("call_1", "search", arguments='{"q": "test"}'), + ], + role="assistant", + ), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert "response.output_text.delta" in types + assert "response.output_text.done" in types + assert "response.function_call_arguments.delta" in types + assert "response.function_call_arguments.done" in types + + async def test_different_function_call_ids_produce_separate_items(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[Content.from_function_call("call_1", "func_a", arguments='{"x":1}')], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_function_call("call_2", "func_b", arguments='{"y":2}')], + role="assistant", + ), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + # Two separate function call items + assert types.count("response.output_item.added") == 2 + assert types.count("response.function_call_arguments.done") == 2 + + async def test_mcp_tool_call_streaming(self) -> None: + agent = _make_agent( + stream_updates=[ + AgentResponseUpdate( + contents=[ + Content( + type="mcp_server_tool_call", + server_name="my_server", + tool_name="search", + arguments='{"query":', + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[ + Content( + type="mcp_server_tool_call", + server_name="my_server", + tool_name="search", + arguments=' "test"}', + ) + ], + role="assistant", + ), + ] + ) + server = _make_server(agent) + resp = await _post(server, stream=True) + + assert resp.status_code == 200 + events = _parse_sse_events(resp.text) + types = _sse_event_types(events) + + assert types[0] == "response.created" + assert types[-1] == "response.completed" + assert "response.output_item.added" in types + assert "response.output_item.done" in types + + +# endregion + + +# region _to_message conversion + + +class TestToMessage: + """Tests for _to_message covering all supported OutputItem types.""" + + def test_output_message(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent + + item = OutputItemOutputMessage({ + "type": "output_message", + "role": "assistant", + "content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "hello"})], + "status": "completed", + "id": "msg-1", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].type == "text" + assert msg.contents[0].text == "hello" + + def test_message(self) -> None: + from azure.ai.agentserver.responses.models import MessageContentInputTextContent, OutputItemMessage + + item = OutputItemMessage({ + "type": "message", + "role": "user", + "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})], + }) + msg = _to_message(item) + assert msg.role == "user" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "hi" + + def test_function_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemFunctionToolCall + + item = OutputItemFunctionToolCall({ + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "NYC"}', + "status": "completed", + "id": "fc-1", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].name == "get_weather" + + def test_function_call_output(self) -> None: + from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam + + item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"}) + msg = _to_message(item) # type: ignore[arg-type] + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].result == "sunny" + + def test_reasoning(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemReasoningItem, SummaryTextContent + + item = OutputItemReasoningItem({ + "type": "reasoning", + "id": "r-1", + "summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})], + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "thinking hard" + + def test_reasoning_no_summary(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemReasoningItem + + item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"}) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents == [] + + def test_mcp_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemMcpToolCall + + item = OutputItemMcpToolCall({ + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"q": "test"}', + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "mcp_server_tool_call" + assert msg.contents[0].server_name == "my_server" + assert msg.contents[0].tool_name == "search" + + def test_mcp_approval_request(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest + + item = OutputItemMcpApprovalRequest({ + "type": "mcp_approval_request", + "id": "apr-1", + "server_label": "srv", + "name": "dangerous_tool", + "arguments": "{}", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_approval_request" + + def test_mcp_approval_response(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource + + item = OutputItemMcpApprovalResponseResource({ + "type": "mcp_approval_response", + "id": "resp-1", + "approval_request_id": "apr-1", + "approve": True, + }) + msg = _to_message(item) + assert msg.role == "user" + assert msg.contents[0].type == "function_approval_response" + assert msg.contents[0].approved is True + + def test_code_interpreter_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemCodeInterpreterToolCall + + item = OutputItemCodeInterpreterToolCall({ + "type": "code_interpreter_call", + "id": "ci-1", + "status": "completed", + "container_id": "c-1", + "code": "print('hi')", + "outputs": [], + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "code_interpreter_tool_call" + + def test_image_generation_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall + + item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"}) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "image_generation_tool_call" + + def test_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import ( + FunctionShellAction, + FunctionShellCallEnvironment, + OutputItemFunctionShellCall, + ) + + item = OutputItemFunctionShellCall({ + "type": "shell_call", + "id": "sc-1", + "call_id": "call_sc", + "action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}), + "status": "completed", + "environment": FunctionShellCallEnvironment({"type": "local"}), + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["ls", "-la"] + assert msg.contents[0].call_id == "call_sc" + + def test_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ( + FunctionShellCallOutputContent, + FunctionShellCallOutputExitOutcome, + OutputItemFunctionShellCallOutput, + ) + + item = OutputItemFunctionShellCallOutput({ + "type": "shell_call_output", + "id": "sco-1", + "call_id": "call_sc", + "status": "completed", + "output": [ + FunctionShellCallOutputContent({ + "stdout": "file.txt", + "stderr": "", + "outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}), + }) + ], + "max_output_length": 1024, + }) + msg = _to_message(item) + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + assert msg.contents[0].call_id == "call_sc" + + def test_local_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import LocalShellExecAction, OutputItemLocalShellToolCall + + item = OutputItemLocalShellToolCall({ + "type": "local_shell_call", + "id": "lsc-1", + "call_id": "call_lsc", + "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), + "status": "completed", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["echo", "hello"] + + def test_local_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemLocalShellToolCallOutput + + item = OutputItemLocalShellToolCallOutput({ + "type": "local_shell_call_output", + "id": "lsco-1", + "output": "hello\n", + }) + msg = _to_message(item) + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + + def test_file_search_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemFileSearchToolCall + + item = OutputItemFileSearchToolCall({ + "type": "file_search_call", + "id": "fs-1", + "status": "completed", + "queries": ["what is AI"], + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "file_search" + assert '"what is AI"' in (msg.contents[0].arguments or "") + + def test_web_search_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemWebSearchToolCall, WebSearchActionSearch + + item = OutputItemWebSearchToolCall({ + "type": "web_search_call", + "id": "ws-1", + "status": "completed", + "action": WebSearchActionSearch({"type": "search", "query": "test"}), + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "web_search" + + def test_computer_call(self) -> None: + from azure.ai.agentserver.responses.models import ComputerAction, OutputItemComputerToolCall + + item = OutputItemComputerToolCall({ + "type": "computer_call", + "id": "cc-1", + "call_id": "call_cc", + "action": ComputerAction({"type": "click"}), + "pending_safety_checks": [], + "status": "completed", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "computer_use" + + def test_computer_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ( + ComputerScreenshotImage, + OutputItemComputerToolCallOutputResource, + ) + + item = OutputItemComputerToolCallOutputResource({ + "type": "computer_call_output", + "call_id": "call_cc", + "output": ComputerScreenshotImage({ + "type": "computer_screenshot", + "image_url": "data:image/png;base64,abc", + }), + }) + msg = _to_message(item) + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_cc" + + def test_custom_tool_call(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemCustomToolCall + + item = OutputItemCustomToolCall({ + "type": "custom_tool_call", + "call_id": "call_ct", + "name": "my_tool", + "input": '{"key": "value"}', + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "my_tool" + assert msg.contents[0].arguments == '{"key": "value"}' + + def test_custom_tool_call_output(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput + + item = OutputItemCustomToolCallOutput({ + "type": "custom_tool_call_output", + "call_id": "call_ct", + "output": "result text", + }) + msg = _to_message(item) + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "result text" + + def test_apply_patch_call(self) -> None: + from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall + + item = OutputItemApplyPatchToolCall({ + "type": "apply_patch_call", + "id": "ap-1", + "call_id": "call_ap", + "status": "completed", + "operation": ApplyPatchUpdateFileOperation({ + "type": "update_file", + "path": "file.py", + "diff": "+ new line", + }), + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "apply_patch" + + def test_apply_patch_call_output(self) -> None: + from azure.ai.agentserver.responses.models import OutputItemApplyPatchToolCallOutput + + item = OutputItemApplyPatchToolCallOutput({ + "type": "apply_patch_call_output", + "id": "apo-1", + "call_id": "call_ap", + "status": "completed", + "output": "patch applied", + }) + msg = _to_message(item) + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "patch applied" + + def test_oauth_consent_request(self) -> None: + from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem + + item = OAuthConsentRequestOutputItem({ + "type": "oauth_consent_request", + "id": "oauth-1", + "consent_link": "https://example.com/consent", + "server_label": "my_server", + }) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "oauth_consent_request" + assert msg.contents[0].consent_link == "https://example.com/consent" + + def test_structured_outputs_dict(self) -> None: + from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem + + item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}}) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].type == "text" + assert json.loads(msg.contents[0].text or "") == {"answer": 42} + + def test_structured_outputs_string(self) -> None: + from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem + + item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"}) + msg = _to_message(item) + assert msg.role == "assistant" + assert msg.contents[0].text == "plain text" + + def test_unsupported_type_raises(self) -> None: + from azure.ai.agentserver.responses.models import OutputItem + + item = OutputItem({"type": "some_unknown_type"}) + with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"): + _to_message(item) + + +# endregion diff --git a/python/pyproject.toml b/python/pyproject.toml index a3876f34dd..b1e455719f 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -41,6 +41,7 @@ dev = [ "pyright==1.1.408", "mcp[ws]==1.27.0", "opentelemetry-sdk==1.40.0", + "azure-monitor-opentelemetry==1.8.7", #tasks "poethepoet==0.42.1", "rich>=13.7.1,<15.0.0", @@ -79,6 +80,7 @@ agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-durabletask = { workspace = true } agent-framework-foundry = { workspace = true } +agent-framework-foundry-hosting = { workspace = true } agent-framework-foundry-local = { workspace = true } agent-framework-gemini = { workspace = true } agent-framework-github-copilot = { workspace = true } diff --git a/python/samples/02-agents/observability/README.md b/python/samples/02-agents/observability/README.md index 33866ffc89..95aa3fc174 100644 --- a/python/samples/02-agents/observability/README.md +++ b/python/samples/02-agents/observability/README.md @@ -347,28 +347,29 @@ setup_observability( ``` **After (Current):** + ```python -# For Microsoft Foundry projects from agent_framework.foundry import FoundryChatClient -from azure.identity import AzureCliCredential - -client = FoundryChatClient( - project_endpoint="https://your-project.services.ai.azure.com", - model="gpt-4o", - credential=AzureCliCredential(), -) -await client.configure_azure_monitor(enable_live_metrics=True) - -# For non-Azure AI projects -from azure.monitor.opentelemetry import configure_azure_monitor from agent_framework.observability import create_resource, enable_instrumentation +from azure.identity import AzureCliCredential +from azure.monitor.opentelemetry import configure_azure_monitor -configure_azure_monitor( - connection_string="InstrumentationKey=...", - resource=create_resource(), - enable_live_metrics=True, -) -enable_instrumentation() +async def main(): + # For Microsoft Foundry projects + client = FoundryChatClient( + project_endpoint="https://your-project.services.ai.azure.com", + model="gpt-4o", + credential=AzureCliCredential(), + ) + await client.configure_azure_monitor(enable_live_metrics=True) + + # For non-Azure AI projects + configure_azure_monitor( + connection_string="InstrumentationKey=...", + resource=create_resource(), + enable_live_metrics=True, + ) + enable_instrumentation() ``` ### Console Output diff --git a/python/samples/04-hosting/foundry-hosted-agents/README.md b/python/samples/04-hosting/foundry-hosted-agents/README.md new file mode 100644 index 0000000000..dcb7dcd24d --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/README.md @@ -0,0 +1,56 @@ +# Foundry Hosted Agents Samples + +This directory contains samples that demonstrate how to use the Agent Framework to host agents on Foundry with different capabilities and configurations. Each sample includes a README with instructions on how to set up, run, and interact with the agent. + +Read more about Foundry Hosted Agents [here](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents). + +## Environment setup + +1. Navigate to the sample directory you want to run. For example: + + ```bash + python -m venv .venv + + # Windows + .venv\Scripts\Activate + + # macOS/Linux + source .venv/bin/activate + ``` + +2. Install dependencies: + + ```bash + pip install -r requirements.txt + ``` + +3. Create a `.env` file with your Foundry configuration following the `env.example` file in the sample. + +4. Make sure you are logged in with the Azure CLI: + + ```bash + az login + ``` + +## Deploying to a Docker container + +Navigate to the sample directory and build the Docker image: + +```bash +docker build -t hosted-agent-sample . +``` + +Run the container, passing in the required environment variables: + +```bash +docker run -p 8088:8088 \ + -e FOUNDRY_PROJECT_ENDPOINT= \ + -e MODEL_DEPLOYMENT_NAME= \ + hosted-agent-sample +``` + +The server will be available at `http://localhost:8088`. You can send requests using the same `curl` command shown above. + +## Deploying to Foundry + +Follow this [guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent?tabs=bash#configure-your-agent) to deploy your agent to Foundry. diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.env.example b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.env.example new file mode 100644 index 0000000000..fe302a8adb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/Dockerfile similarity index 100% rename from python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/Dockerfile rename to python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/Dockerfile diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md new file mode 100644 index 0000000000..8c14ea897e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/README.md @@ -0,0 +1,44 @@ +# Basic example of hosting an agent with the `invocations` API + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +### Interacting with the agent + +Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}' +``` + +The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response: + +```bash +HTTP/1.1 200 +content-length: 34 +content-type: application/json +x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83 +x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17 +x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12) +date: Fri, 17 Apr 2026 23:46:44 GMT +server: hypercorn-h11 + +{"response":"Hi! How can I help?"} +``` + +### Multi-turn conversation + +To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example: + +```bash +curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.manifest.yaml new file mode 100644 index 0000000000..9ef34e5469 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.manifest.yaml @@ -0,0 +1,23 @@ +name: agent-framework-agent-basic-invocations +description: > + A basic Agent Framework agent hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Invocations Protocol + - Streaming +template: + name: agent-framework-agent-basic-invocations + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.yaml new file mode 100644 index 0000000000..152179a8e6 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-basic-invocations +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/main.py new file mode 100644 index 0000000000..e939680a59 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/main.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import InvocationsHostServer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = InvocationsHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/requirements.txt new file mode 100644 index 0000000000..f7dc62f3e3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/01_basic/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.env.example b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.env.example new file mode 100644 index 0000000000..fe302a8adb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/Dockerfile similarity index 100% rename from python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/Dockerfile rename to python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/Dockerfile diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md new file mode 100644 index 0000000000..e04207e1d0 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/README.md @@ -0,0 +1,46 @@ +# Basic example of hosting an agent with the `invocations` API + +This is the same as the [01_basic](../01_basic/README.md) example, but demonstrates the "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. This is useful when you want to override the default behavior for certain requests or add custom processing logic. + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +### Interacting with the agent + +Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}' +``` + +The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response: + +```bash +HTTP/1.1 200 +content-length: 34 +content-type: application/json +x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83 +x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17 +x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12) +date: Fri, 17 Apr 2026 23:46:44 GMT +server: hypercorn-h11 + +{"response":"Hi! How can I help?"} +``` + +### Multi-turn conversation + +To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example: + +```bash +curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.manifest.yaml new file mode 100644 index 0000000000..9ef34e5469 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.manifest.yaml @@ -0,0 +1,23 @@ +name: agent-framework-agent-basic-invocations +description: > + A basic Agent Framework agent hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Invocations Protocol + - Streaming +template: + name: agent-framework-agent-basic-invocations + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.yaml new file mode 100644 index 0000000000..152179a8e6 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: agent-framework-agent-basic-invocations +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: '0.25' + memory: '0.5Gi' diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py new file mode 100644 index 0000000000..3d63ac211c --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/main.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +from collections.abc import AsyncGenerator + +from agent_framework import Agent, AgentSession +from agent_framework.foundry import FoundryChatClient +from azure.ai.agentserver.invocations import InvocationAgentServerHost +from azure.identity import DefaultAzureCredential +from dotenv import load_dotenv +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse + +# Load environment variables from .env file +load_dotenv() + + +# In-memory session store — keyed by session ID. +# WARNING: This is lost on restart. Use durable storage in production. +_sessions: dict[str, AgentSession] = {} + +# Create the agent +client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=DefaultAzureCredential(), +) + +agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, +) + +app = InvocationAgentServerHost() + + +@app.invoke_handler +async def handle_invoke(request: Request): + """Handle streaming multi-turn chat with Azure OpenAI via SSE.""" + data = await request.json() + session_id = request.state.session_id + + stream = data.get("stream", False) + user_message = data.get("message", None) + if user_message is None: + error = "Missing 'message' in request" + if stream: + return StreamingResponse(content=error, status_code=400) + return Response(content=error, status_code=400) + + session = _sessions.setdefault(session_id, AgentSession(session_id=session_id)) + + if stream: + + async def stream_response() -> AsyncGenerator[str]: + async for update in agent.run(user_message, session=session, stream=True): + yield update.text + + return StreamingResponse( + stream_response(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}, + ) + + response = await agent.run([user_message], session=session, stream=stream) + return JSONResponse({"response": response.text}) + + +if __name__ == "__main__": + app.run() diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/requirements.txt new file mode 100644 index 0000000000..2abc225e74 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/02_break_glass/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +azure-ai-agentserver-invocations \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/invocations/README.md b/python/samples/04-hosting/foundry-hosted-agents/invocations/README.md new file mode 100644 index 0000000000..0cba373c7b --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/invocations/README.md @@ -0,0 +1,8 @@ +# Hosting agents with Foundry Hosting and the `invocations` API + +This folder contains a list of samples that show how to host agents using the `invocations` API and deploy them to Foundry Hosting. + +| Sample | Description | +| --- | --- | +| [01_basic](./01_basic) | A basic example of hosting an agent with the `invocations` API and carrying on a multi-turn conversation. | +| [02_break_glass](./02_break_glass) | An example of hosting an agent with the `invocations` API and a "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. | diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.env.example new file mode 100644 index 0000000000..fe302a8adb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/Dockerfile similarity index 100% rename from python/samples/05-end-to-end/hosted_agents/agents_in_workflow/Dockerfile rename to python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/Dockerfile diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/README.md new file mode 100644 index 0000000000..9e4b36a77d --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/README.md @@ -0,0 +1,31 @@ +# Basic example of hosting an agent with the `responses` API + +This agent only contains an instruction (personal). It's the most basic agent with an LLM and no tools. + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +## Interacting with the agent + +Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi"}' +``` + +## Multi-turn conversation + +To have a multi-turn conversation with the agent, include the previous response id in the request body. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How are you?", "previous_response_id": "REPLACE_WITH_PREVIOUS_RESPONSE_ID"}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.manifest.yaml new file mode 100644 index 0000000000..4f4749af25 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.manifest.yaml @@ -0,0 +1,23 @@ +name: agent-framework-agent-basic +description: > + A basic Agent Framework agent hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-agent-basic + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.yaml new file mode 100644 index 0000000000..5b14606961 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/agent.yaml @@ -0,0 +1,8 @@ +kind: hosted +name: agent-framework-agent-basic +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/main.py new file mode 100644 index 0000000000..4b10c9a089 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/main.py @@ -0,0 +1,36 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os + +from agent_framework import Agent +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/requirements.txt new file mode 100644 index 0000000000..f7dc62f3e3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/01_basic/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.env.example new file mode 100644 index 0000000000..fe302a8adb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/Dockerfile similarity index 50% rename from python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile rename to python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/Dockerfile index 077fb78345..eaffb94f19 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/Dockerfile +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.14-slim +FROM python:3.12-slim WORKDIR /app -COPY ./ . +COPY . user_agent/ +WORKDIR /app/user_agent -RUN pip install --upgrade pip && \ - if [ -f requirements.txt ]; then \ +RUN if [ -f requirements.txt ]; then \ pip install -r requirements.txt; \ else \ echo "No requirements.txt found"; \ @@ -13,4 +13,4 @@ RUN pip install --upgrade pip && \ EXPOSE 8088 -CMD ["python", "main.py"] +CMD ["python", "main.py"] \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/README.md new file mode 100644 index 0000000000..d8bfd7146e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/README.md @@ -0,0 +1,27 @@ +# Basic example of hosting an agent with the `responses` API and local tools + +This agent is equipped with a function tool and a local shell tool. + +> We recommend deploying this sample on a local container or to Foundry Hosting because the agent has access to a local shell tool, which can run arbitrary commands on the machine. + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +## Interacting with the agent + +Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}' + +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List the files in the current directory."}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.manifest.yaml new file mode 100644 index 0000000000..ea8c6010ec --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.manifest.yaml @@ -0,0 +1,23 @@ +name: agent-framework-agent-with-local-tools +description: > + An Agent Framework agent with local tools hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-agent-with-local-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.yaml new file mode 100644 index 0000000000..59fc4f8f73 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/agent.yaml @@ -0,0 +1,8 @@ +kind: hosted +name: agent-framework-agent-with-local-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py new file mode 100644 index 0000000000..7cba9b821e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py @@ -0,0 +1,74 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os +import subprocess +from random import randint + +from agent_framework import Agent, tool +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv +from pydantic import Field +from typing import Annotated + +# Load environment variables from .env file +load_dotenv() + + +@tool(approval_mode="never_require") +def get_weather( + location: Annotated[str, Field(description="The location to get the weather for.")], +) -> str: + """Get the weather for a given location.""" + conditions = ["sunny", "cloudy", "rainy", "stormy"] + return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C." + + +@tool(approval_mode="always_require") +def run_bash(command: str) -> str: + """Execute a shell command locally and return stdout, stderr, and exit code.""" + try: + result = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=30, + ) + parts: list[str] = [] + if result.stdout: + parts.append(result.stdout) + if result.stderr: + parts.append(f"stderr: {result.stderr}") + parts.append(f"exit_code: {result.returncode}") + return "\n".join(parts) + except subprocess.TimeoutExpired: + return "Command timed out after 30 seconds" + except Exception as e: + return f"Error executing command: {e}" + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + tools=[get_weather, run_bash], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/requirements.txt new file mode 100644 index 0000000000..f7dc62f3e3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.env.example new file mode 100644 index 0000000000..e76ca18af9 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/.env.example @@ -0,0 +1,4 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." +TOOLBOX_NAME="..." +GITHUB_PAT="..." \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/Dockerfile similarity index 50% rename from python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile rename to python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/Dockerfile index 077fb78345..eaffb94f19 100644 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/Dockerfile +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/Dockerfile @@ -1,11 +1,11 @@ -FROM python:3.14-slim +FROM python:3.12-slim WORKDIR /app -COPY ./ . +COPY . user_agent/ +WORKDIR /app/user_agent -RUN pip install --upgrade pip && \ - if [ -f requirements.txt ]; then \ +RUN if [ -f requirements.txt ]; then \ pip install -r requirements.txt; \ else \ echo "No requirements.txt found"; \ @@ -13,4 +13,4 @@ RUN pip install --upgrade pip && \ EXPOSE 8088 -CMD ["python", "main.py"] +CMD ["python", "main.py"] \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/README.md new file mode 100644 index 0000000000..0c41817a4e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/README.md @@ -0,0 +1,25 @@ +# Basic example of hosting an agent with the `responses` API and a remote MCP + +This agent is equipped with a GitHub MCP server and a Foundry Toolbox, which are both remote MCPs. + +> Note that there are other ways to interact with Foundry toolboxes. Using it as a MCP is just one of the options. + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +## Interacting with the agent + +Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List all the repositories I own on GitHub."}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.manifest.yaml new file mode 100644 index 0000000000..4f1bd75d3e --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.manifest.yaml @@ -0,0 +1,27 @@ +name: agent-framework-agent-with-remote-mcp-tools +description: > + An Agent Framework agent with remote MCP tools hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-agent-with-remote-mcp-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" + - name: GITHUB_PAT + value: ${GITHUB_PAT} + - name: TOOLBOX_NAME + value: ${TOOLBOX_NAME} +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.yaml new file mode 100644 index 0000000000..d0ce27c958 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/agent.yaml @@ -0,0 +1,8 @@ +kind: hosted +name: agent-framework-agent-with-remote-mcp-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/main.py new file mode 100644 index 0000000000..a1c2718887 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/main.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os + +import httpx +from agent_framework import Agent, MCPStreamableHTTPTool +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +class ToolboxAuth(httpx.Auth): + """httpx Auth that injects a fresh bearer token on every request.""" + + def auth_flow(self, request: httpx.Request): + credential = AzureCliCredential() + token = credential.get_token("https://ai.azure.com/.default").token + request.headers["Authorization"] = f"Bearer {token}" + yield request + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + # Foundry Toolbox as a MCP tool + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + toolbox_name = os.environ["TOOLBOX_NAME"] + toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{toolbox_name}/mcp?api-version=v1" + http_client = httpx.AsyncClient(auth=ToolboxAuth(), headers={"Foundry-Features": "Toolboxes=V1Preview"}) + foundry_mcp_tool = MCPStreamableHTTPTool( + name="toolbox", + url=toolbox_endpoint, + http_client=http_client, + load_prompts=False, + ) + + # GitHub MCP server + github_pat = os.environ["GITHUB_PAT"] + if not github_pat: + raise ValueError( + "GITHUB_PAT environment variable must be set. Create a token at https://github.com/settings/tokens" + ) + + github_mcp_tool = client.get_mcp_tool( + name="GitHub", + url="https://api.githubcopilot.com/mcp/", + headers={ + "Authorization": f"Bearer {github_pat}", + }, + approval_mode="never_require", + ) + + agent = Agent( + client=client, + instructions="You are a friendly assistant. Keep your answers brief.", + tools=[foundry_mcp_tool, github_mcp_tool], + # History will be managed by the hosting infrastructure, thus there + # is no need to store history by the service. Learn more at: + # https://developers.openai.com/api/reference/resources/responses/methods/create + default_options={"store": False}, + ) + + server = ResponsesHostServer(agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/requirements.txt new file mode 100644 index 0000000000..f7dc62f3e3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/03_remote_mcp/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.dockerignore b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.dockerignore new file mode 100644 index 0000000000..008e6e6616 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.dockerignore @@ -0,0 +1,6 @@ +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.Python \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.env.example b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.env.example new file mode 100644 index 0000000000..fe302a8adb --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/.env.example @@ -0,0 +1,2 @@ +FOUNDRY_PROJECT_ENDPOINT="..." +MODEL_DEPLOYMENT_NAME="..." \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/Dockerfile b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/Dockerfile new file mode 100644 index 0000000000..eaffb94f19 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY . user_agent/ +WORKDIR /app/user_agent + +RUN if [ -f requirements.txt ]; then \ + pip install -r requirements.txt; \ + else \ + echo "No requirements.txt found"; \ + fi + +EXPOSE 8088 + +CMD ["python", "main.py"] \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/README.md new file mode 100644 index 0000000000..0d93cf2e62 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/README.md @@ -0,0 +1,23 @@ +# Basic example of hosting an agent with the `responses` API and a workflow + +This sample demonstrates how to host a workflow using the `responses` API. + +## Running the server locally + +### Environment setup + +Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies. + +Run the following command to start the server: + +```bash +python main.py +``` + +## Interacting with the agent + +Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example: + +```bash +curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive."}' +``` diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.manifest.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.manifest.yaml new file mode 100644 index 0000000000..d561ec043a --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.manifest.yaml @@ -0,0 +1,23 @@ +name: agent-framework-workflows +description: > + An Agent Framework workflow hosted by Foundry. +metadata: + tags: + - Agent Framework + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming +template: + name: agent-framework-workflows + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + environment_variables: + - name: MODEL_DEPLOYMENT_NAME + value: "{{MODEL_DEPLOYMENT_NAME}}" +resources: + - kind: model + id: gpt-4.1-mini + name: MODEL_DEPLOYMENT_NAME \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.yaml b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.yaml new file mode 100644 index 0000000000..6afb8b777c --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/agent.yaml @@ -0,0 +1,8 @@ +kind: hosted +name: agent-framework-workflows +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/main.py new file mode 100644 index 0000000000..83e2507b22 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/main.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +import os + +from agent_framework import Agent, AgentExecutor, WorkflowBuilder +from agent_framework.foundry import FoundryChatClient +from agent_framework_foundry_hosting import ResponsesHostServer +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + + +def main(): + client = FoundryChatClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + writer_agent = Agent( + client=client, + instructions=("You are an excellent slogan writer. You create new slogans based on the given topic."), + name="writer", + ) + + legal_agent = Agent( + client=client, + instructions=( + "You are an excellent legal reviewer. " + "Make necessary corrections to the slogan so that it is legally compliant." + ), + name="legal_reviewer", + ) + + format_agent = Agent( + client=client, + instructions=( + "You are an excellent content formatter. " + "You take the slogan and format it in a cool retro style when printing to a terminal." + ), + name="formatter", + ) + + # Set the context mode to `last_agent` so that each agent only sees the output of the + # previous agent instead of the full conversation history + writer_executor = AgentExecutor(writer_agent, context_mode="last_agent") + legal_executor = AgentExecutor(legal_agent, context_mode="last_agent") + format_executor = AgentExecutor(format_agent, context_mode="last_agent") + + workflow_agent = ( + WorkflowBuilder( + start_executor=writer_executor, + # Limiting the output to only the final formatted result. + # If this is not set, all intermediate results will be included in the output. + output_executors=[format_executor], + ) + .add_edge(writer_executor, legal_executor) + .add_edge(legal_executor, format_executor) + .build() + .as_agent() + ) + + server = ResponsesHostServer(workflow_agent) + server.run() + + +if __name__ == "__main__": + main() diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/requirements.txt b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/requirements.txt new file mode 100644 index 0000000000..f7dc62f3e3 --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/04_workflows/requirements.txt @@ -0,0 +1,2 @@ +agent-framework +agent-framework-foundry-hosting \ No newline at end of file diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md new file mode 100644 index 0000000000..072dbea36f --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md @@ -0,0 +1,11 @@ +# Hosting agents with Foundry Hosting and the `responses` API + +This folder contains a list of samples that show how to host agents using the `responses` API and deploy them to Foundry Hosting. + +| Sample | Description | +| --- | --- | +| [01_basic](./01_basic) | A basic example of hosting an agent with the `responses` API and carrying on a multi-turn conversation. | +| [02_local_tools](./02_local_tools) | An example of hosting an agent with the `responses` API and local tools including a function tool and a local shell tool. | +| [03_remote_mcp](./03_remote_mcp) | An example of hosting an agent with the `responses` API and remote MCPs, including a GitHub MCP server and a Foundry Toolbox. | +| [04_workflows](./04_workflows) | An example of hosting a workflow with the `responses` API. | +| [using_deployed_agent.py](./using_deployed_agent.py) | An example of how to use the deployed agent in Agent Framework. | diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py new file mode 100644 index 0000000000..1f3525775a --- /dev/null +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio + +from agent_framework import Agent, AgentResponse, AgentResponseUpdate, ResponseStream +from agent_framework.openai import OpenAIChatClient +from typing_extensions import Any + +""" +This script demonstrates how to talk to a deployed agent using the OpenAIChatClient. + +Depending on where you have deployed your agent (local or Foundry Hosting), you may +need to change the base_url when initializing the OpenAIChatClient. +""" + + +async def print_streaming_response(streaming_response: ResponseStream[AgentResponseUpdate, AgentResponse[Any]]) -> None: + async for chunk in streaming_response: + if chunk.text: + print(chunk.text, end="", flush=True) + + +async def main() -> None: + agent = Agent(client=OpenAIChatClient(base_url="http://localhost:8088")) + session = agent.create_session() + + # First turn + query = "Hi!" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + streaming_response = agent.run(query, session=session, stream=True) + await print_streaming_response(streaming_response) + + # Second turn + query = "Your name is Javis. What can you do?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + streaming_response = agent.run(query, session=session, stream=True) + await print_streaming_response(streaming_response) + + # Third turn + query = "What is your name?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + streaming_response = agent.run(query, session=session, stream=True) + await print_streaming_response(streaming_response) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/05-end-to-end/hosted_agents/README.md b/python/samples/05-end-to-end/hosted_agents/README.md deleted file mode 100644 index d45b2f630c..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/README.md +++ /dev/null @@ -1,145 +0,0 @@ -# Hosted Agent Samples - -These samples demonstrate how to build and host AI agents in Python using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) together with Microsoft Agent Framework. Each sample runs locally as a hosted agent and includes `Dockerfile` and `agent.yaml` assets for deployment to Microsoft Foundry. - -## Samples - -| Sample | Description | -| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| [`agent_with_hosted_mcp`](./agent_with_hosted_mcp/) | Hosted MCP tool that connects to Microsoft Learn via `https://learn.microsoft.com/api/mcp` | -| [`agent_with_text_search_rag`](./agent_with_text_search_rag/) | Retrieval-augmented generation using a custom `ContextProvider` with Contoso Outdoors sample data | -| [`agents_in_workflow`](./agents_in_workflow/) | Concurrent workflow that combines researcher, marketer, and legal specialist agents | -| [`agent_with_local_tools`](./agent_with_local_tools/) | Local Python tool execution for Seattle hotel search | -| [`writer_reviewer_agents_in_workflow`](./writer_reviewer_agents_in_workflow/) | Writer/Reviewer workflow using `FoundryChatClient` | - -## Common Prerequisites - -Before running any sample, ensure you have: - -1. Python 3.10 or later -2. [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed -3. An Azure OpenAI resource or a Microsoft Foundry project with a chat model deployment - -### Authenticate with Azure CLI - -All samples rely on Azure credentials. For local development, the simplest approach is Azure CLI authentication: - -```powershell -az login -az account show -``` - -## Running a Sample - -Each sample folder contains its own `requirements.txt`. Run commands from the specific sample directory you want to try. - -### Recommended: `uv` - -The sample dependencies include preview packages, so allow prerelease installs: - -```powershell -cd -uv venv .venv -uv pip install --prerelease=allow -r requirements.txt -uv run main.py -``` - -### Alternative: `venv` - -Windows PowerShell: - -```powershell -cd -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r requirements.txt -python main.py -``` - -macOS/Linux: - -```bash -cd -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -python main.py -``` - -Each sample starts a hosted agent locally on `http://localhost:8088/`. - -## Environment Variable Setup - -You can either export variables in your shell or create a local `.env` file in the sample directory. - -Example `.env` for Azure OpenAI samples: - -```dotenv -AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ -AZURE_OPENAI_MODEL=gpt-4.1 -``` - -Example `.env` for Foundry project samples: - -```dotenv -FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -FOUNDRY_MODEL=gpt-4.1 -``` - -## Interacting with the Agent - -After starting a sample, send requests to the Responses endpoint. - -PowerShell: - -```powershell -$body = @{ - input = "Your question here" - stream = $false -} | ConvertTo-Json - -Invoke-RestMethod -Uri "http://localhost:8088/responses" -Method Post -Body $body -ContentType "application/json" -``` - -curl: - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ - -d '{"input":"Your question here","stream":false}' -``` - -Example prompts by sample: - -| Sample | Example input | -| ------------------------------------ | ---------------------------------------------------------------------------- | -| `agent_with_hosted_mcp` | `What does Microsoft Learn say about managed identities in Azure?` | -| `agent_with_text_search_rag` | `What is Contoso Outdoors' return policy for refunds?` | -| `agents_in_workflow` | `Create a launch strategy for a budget-friendly electric SUV.` | -| `agent_with_local_tools` | `Find me Seattle hotels from 2025-03-15 to 2025-03-18 under $200 per night.` | -| `writer_reviewer_agents_in_workflow` | `Write a slogan for a new affordable electric SUV.` | - -## Deploying to Microsoft Foundry - -Each sample includes a `Dockerfile` and `agent.yaml` for deployment. For deployment steps, follow the hosted agents guidance in Microsoft Foundry: - -- [Hosted agents overview](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents) -- [Create a hosted agent with CLI](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?tabs=cli#create-a-hosted-agent) -- [Create a hosted agent in Visual Studio Code](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/vs-code-agents-workflow-pro-code?tabs=windows-powershell&pivots=python) - -## Troubleshooting - -### Missing Azure credentials - -If startup fails with authentication errors, run `az login` and verify the selected subscription with `az account show`. - -### Preview package install issues - -These samples depend on preview packages such as `azure-ai-agentserver-agentframework`. Use `uv pip install --prerelease=allow -r requirements.txt` or `pip install -r requirements.txt`. - -### ARM64 container images fail after deployment - -If you build images locally on ARM64 hardware such as Apple Silicon, build for `linux/amd64`: - -```bash -docker build --platform=linux/amd64 -t image . -``` diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml deleted file mode 100644 index 6e46adcaed..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/agent.yaml +++ /dev/null @@ -1,30 +0,0 @@ -# Unique identifier/name for this agent -name: agent-with-hosted-mcp -# Brief description of what this agent does -description: > - An AI agent that uses Azure OpenAI with a Hosted Model Context Protocol (MCP) server. - The agent answers questions by searching Microsoft Learn documentation using MCP tools. -metadata: - # Categorization tags for organizing and discovering agents - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Model Context Protocol - - MCP -template: - name: agent-with-hosted-mcp - # The type of agent - "hosted" for HOBO, "container" for COBO - kind: hosted - protocols: - - protocol: responses - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_MODEL - value: "{{chat}}" -resources: - - kind: model - id: gpt-4o-mini - name: chat diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py deleted file mode 100644 index 8d87046fa3..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/main.py +++ /dev/null @@ -1,34 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient -from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] -from azure.identity import AzureCliCredential -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -def main(): - client = FoundryChatClient(credential=AzureCliCredential()) - - # Create MCP tool configuration as dict - mcp_tool = client.get_mcp_tool( - name="Microsoft_Learn_MCP", - url="https://learn.microsoft.com/api/mcp", - ) - - # Create an Agent using the Azure OpenAI Chat Client with a MCP Tool that connects to Microsoft Learn MCP - agent = Agent( - client=client, - name="DocsAgent", - instructions="You are a helpful assistant that can help with microsoft documentation questions.", - tools=[mcp_tool], - ) - # Run the agent as a hosted agent - from_agent_framework(agent).run() - - -if __name__ == "__main__": - main() diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt deleted file mode 100644 index 250c059d77..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_hosted_mcp/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-ai-agentserver-agentframework==1.0.0b16 -agent-framework diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore deleted file mode 100644 index 779bc67aae..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.dockerignore +++ /dev/null @@ -1,66 +0,0 @@ -# Virtual environments -.venv/ -venv/ -env/ -.python-version - -# Environment files with secrets -.env -.env.* -*.local - -# Python build artifacts -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Testing -.tox/ -.nox/ -.coverage -.coverage.* -htmlcov/ -.pytest_cache/ -.mypy_cache/ - -# IDE and OS files -.DS_Store -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# Foundry config -.foundry/ -build-source-*/ - -# Git -.git/ -.gitignore - -# Docker -.dockerignore - -# Documentation -docs/ -*.md -!README.md -LICENSE diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample deleted file mode 100644 index 67bcea72f3..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/.env.sample +++ /dev/null @@ -1,3 +0,0 @@ -# IMPORTANT: Never commit .env to version control - add it to .gitignore -FOUNDRY_PROJECT_ENDPOINT= -FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md deleted file mode 100644 index 50efdcb7a4..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/README.md +++ /dev/null @@ -1,162 +0,0 @@ -**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). - -Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. - -Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. - -Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. - -# What this sample demonstrates - -This sample demonstrates a **key advantage of code-based hosted agents**: - -- **Local Python tool execution** - Run custom Python functions as agent tools - -Code-based agents can execute **any Python code** you write. This sample includes a Seattle Hotel Agent with a `get_available_hotels` tool that searches for available hotels based on check-in/check-out dates and budget preferences. - -The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry using the Azure Developer CLI. - -## How It Works - -### Local Tools Integration - -In [main.py](main.py), the agent uses a local Python function (`get_available_hotels`) that simulates a hotel availability API. This demonstrates how code-based agents can execute custom server-side logic that prompt agents cannot access. - -The tool accepts: - -- **check_in_date** - Check-in date in YYYY-MM-DD format -- **check_out_date** - Check-out date in YYYY-MM-DD format -- **max_price** - Maximum price per night in USD (optional, defaults to $500) - -### Agent Hosting - -The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), -which provisions a REST API endpoint compatible with the OpenAI Responses protocol. - -### Agent Deployment - -The hosted agent can be deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. - -## Running the Agent Locally - -### Prerequisites - -Before running this sample, ensure you have: - -1. **Microsoft Foundry Project** - - Project created in [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry#microsoft-foundry-portals) - - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - - Note your project endpoint URL and model deployment name - -2. **Azure CLI** - - Installed and authenticated - - Run `az login` and verify with `az account show` - -3. **Python 3.10 or higher** - - Verify your version: `python --version` - -### Environment Variables - -Set the following environment variables (matching `agent.yaml`): - -- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) - -This sample loads environment variables from a local `.env` file if present. - -Create a `.env` file in this directory with the following content: - -``` -FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -FOUNDRY_MODEL=gpt-4.1-mini -``` - -Or set them via PowerShell: - -```powershell -# Replace with your actual values -$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:FOUNDRY_MODEL="gpt-4.1-mini" -``` - -### Running the Sample - -**Recommended (`uv`):** - -We recommend using [uv](https://docs.astral.sh/uv/) to create and manage the virtual environment for this sample. - -```bash -uv venv .venv -uv pip install --prerelease=allow -r requirements.txt -uv run main.py -``` - -The sample depends on preview packages, so `--prerelease=allow` is required when installing with `uv`. - -**Alternative (`venv`):** - -If you do not have `uv` installed, you can use Python's built-in `venv` module instead: - -**Windows (PowerShell):** - -```powershell -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r requirements.txt -python main.py -``` - -**macOS/Linux:** - -```bash -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -python main.py -``` - -This will start the hosted agent locally on `http://localhost:8088/`. - -### Interacting with the Agent - -**PowerShell (Windows):** - -```powershell -$body = @{ - input = "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night" - stream = $false -} | ConvertTo-Json - -Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" -``` - -**Bash/curl (Linux/macOS):** - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ - -d '{"input": "Find me hotels in Seattle for March 20-23, 2025 under $200 per night","stream":false}' -``` - -The agent will use the `get_available_hotels` tool to search for available hotels matching your criteria. - -### Deploying the Agent to Microsoft Foundry - -To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli - -## Troubleshooting - -### Images built on Apple Silicon or other ARM64 machines do not work on our service - -We **recommend using `azd` cloud build**, which always builds images with the correct architecture. - -If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. - -**Fix for local builds** - -Use this command to build the image locally: - -```shell -docker build --platform=linux/amd64 -t image . -``` - -This forces the image to be built for the required `amd64` architecture. diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml deleted file mode 100644 index d18fafc374..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/agent.yaml +++ /dev/null @@ -1,27 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml - -kind: hosted -name: agent-with-local-tools -# Brief description of what this agent does -description: > - A travel assistant agent that helps users find hotels in Seattle. - Demonstrates local Python tool execution - a key advantage of code-based - hosted agents over prompt agents. -metadata: - # Categorization tags for organizing and discovering agents - authors: - - Microsoft - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Local Tools - - Travel Assistant - - Hotel Search -protocols: - - protocol: responses - version: v1 -environment_variables: - - name: FOUNDRY_PROJECT_ENDPOINT - value: ${FOUNDRY_PROJECT_ENDPOINT} - - name: FOUNDRY_MODEL - value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py deleted file mode 100644 index 3bdecb5100..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py +++ /dev/null @@ -1,144 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -""" -Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -Uses Microsoft Agent Framework with Azure AI Foundry. -Ready for deployment to Foundry Hosted Agent service. -""" - -import asyncio -import os -from datetime import datetime -from typing import Annotated - -from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient -from azure.ai.agentserver.agentframework import from_agent_framework -from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential - -# Configure these for your Foundry project -# Read the explicit variables present in the .env file -FOUNDRY_PROJECT_ENDPOINT = os.getenv("FOUNDRY_PROJECT_ENDPOINT") # e.g., "https://.services.ai.azure.com" -FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" - - -# Simulated hotel data for Seattle -SEATTLE_HOTELS = [ - { - "name": "Contoso Suites", - "price_per_night": 189, - "rating": 4.5, - "location": "Downtown", - }, - { - "name": "Fabrikam Residences", - "price_per_night": 159, - "rating": 4.2, - "location": "Pike Place Market", - }, - { - "name": "Alpine Ski House", - "price_per_night": 249, - "rating": 4.7, - "location": "Seattle Center", - }, - { - "name": "Margie's Travel Lodge", - "price_per_night": 219, - "rating": 4.4, - "location": "Waterfront", - }, - { - "name": "Northwind Inn", - "price_per_night": 139, - "rating": 4.0, - "location": "Capitol Hill", - }, - { - "name": "Relecloud Hotel", - "price_per_night": 99, - "rating": 3.8, - "location": "University District", - }, -] - - -def get_available_hotels( - check_in_date: Annotated[str, "Check-in date in YYYY-MM-DD format"], - check_out_date: Annotated[str, "Check-out date in YYYY-MM-DD format"], - max_price: Annotated[int, "Maximum price per night in USD (optional)"] = 500, -) -> str: - """ - Get available hotels in Seattle for the specified dates. - This simulates a call to a fake hotel availability API. - """ - try: - # Parse dates - check_in = datetime.strptime(check_in_date, "%Y-%m-%d") - check_out = datetime.strptime(check_out_date, "%Y-%m-%d") - - # Validate dates - if check_out <= check_in: - return "Error: Check-out date must be after check-in date." - - nights = (check_out - check_in).days - - # Filter hotels by price - available_hotels = [hotel for hotel in SEATTLE_HOTELS if hotel["price_per_night"] <= max_price] - - if not available_hotels: - return f"No hotels found in Seattle within your budget of ${max_price}/night." - - # Build response - result = f"Available hotels in Seattle from {check_in_date} to {check_out_date} ({nights} nights):\n\n" - - for hotel in available_hotels: - total_cost = hotel["price_per_night"] * nights - result += f"**{hotel['name']}**\n" - result += f" Location: {hotel['location']}\n" - result += f" Rating: {hotel['rating']}/5\n" - result += f" ${hotel['price_per_night']}/night (Total: ${total_cost})\n\n" - - return result - - except ValueError as e: - return f"Error parsing dates. Please use YYYY-MM-DD format. Details: {str(e)}" - - -def get_credential(): - """Will use Managed Identity when running in Azure, otherwise falls back to Azure CLI Credential.""" - return ManagedIdentityCredential() if os.getenv("MSI_ENDPOINT") else AzureCliCredential() - - -async def main(): - """Main function to run the agent as a web server.""" - async with get_credential() as credential: - client = FoundryChatClient( - project_endpoint=FOUNDRY_PROJECT_ENDPOINT, - model=FOUNDRY_MODEL, - credential=credential, - ) - agent = Agent( - client=client, - name="SeattleHotelAgent", - instructions="""You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. - -When a user asks about hotels in Seattle: -1. Ask for their check-in and check-out dates if not provided -2. Ask about their budget preferences if not mentioned -3. Use the get_available_hotels tool to find available options -4. Present the results in a friendly, informative way -5. Offer to help with additional questions about the hotels or Seattle - -Be conversational and helpful. If users ask about things outside of Seattle hotels, -politely let them know you specialize in Seattle hotel recommendations.""", - tools=[get_available_hotels], - ) - - print("Seattle Hotel Agent Server running on http://localhost:8088") - server = from_agent_framework(agent) - await server.run_async() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt deleted file mode 100644 index 247d88d7de..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-ai-agentserver-agentframework==1.0.0b16 -agent-framework-foundry \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml deleted file mode 100644 index 13938f6a51..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/agent.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# Unique identifier/name for this agent -name: agent-with-text-search-rag -# Brief description of what this agent does -description: > - An AI agent that uses a ContextProvider for retrieval augmented generation (RAG) capabilities. - The agent runs searches against an external knowledge base before each model invocation and - injects the results into the model context. It can answer questions about Contoso Outdoors - policies and products, including return policies, refunds, shipping options, and product care - instructions such as tent maintenance. -metadata: - # Categorization tags for organizing and discovering agents - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Retrieval-Augmented Generation - - RAG -template: - name: agent-with-text-search-rag - # The type of agent - "hosted" for HOBO, "container" for COBO - kind: hosted - protocols: - - protocol: responses - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_MODEL - value: "{{chat}}" -resources: - - kind: model - id: gpt-4o-mini - name: chat diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py deleted file mode 100644 index d72d041f0b..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/main.py +++ /dev/null @@ -1,123 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import json -import sys -from dataclasses import dataclass -from typing import Any - -from agent_framework import Agent, AgentSession, ContextProvider, Message, SessionContext -from agent_framework.foundry import FoundryChatClient -from azure.ai.agentserver.agentframework import from_agent_framework # pyright: ignore[reportUnknownVariableType] -from azure.identity import DefaultAzureCredential -from dotenv import load_dotenv - -if sys.version_info >= (3, 12): - from typing import override -else: - from typing_extensions import override - - -# Load environment variables from .env file -load_dotenv() - - -@dataclass -class TextSearchResult: - source_name: str - source_link: str - text: str - - -class TextSearchContextProvider(ContextProvider): - """A simple context provider that simulates text search results based on keywords in the user's message.""" - - def __init__(self): - super().__init__("text-search") - - def _get_most_recent_message(self, messages: list[Message]) -> Message: - """Helper method to extract the most recent message from the input.""" - if messages: - return messages[-1] - raise ValueError("No messages provided") - - @override - async def before_run( - self, - *, - agent: Any, - session: AgentSession | None, - context: SessionContext, - state: dict[str, Any], - ) -> None: - messages = context.get_messages() - if not messages: - return - message = self._get_most_recent_message(messages) - query = message.text.lower() - - results: list[TextSearchResult] = [] - if "return" in query and "refund" in query: - results.append( - TextSearchResult( - source_name="Contoso Outdoors Return Policy", - source_link="https://contoso.com/policies/returns", - text=( - "Customers may return any item within 30 days of delivery. " - "Items should be unused and include original packaging. " - "Refunds are issued to the original payment method within 5 business days of inspection." - ), - ) - ) - - if "shipping" in query: - results.append( - TextSearchResult( - source_name="Contoso Outdoors Shipping Guide", - source_link="https://contoso.com/help/shipping", - text=( - "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days " - "within the continental United States. Expedited options are available at checkout." - ), - ) - ) - - if "tent" in query or "fabric" in query: - results.append( - TextSearchResult( - source_name="TrailRunner Tent Care Instructions", - source_link="https://contoso.com/manuals/trailrunner-tent", - text=( - "Clean the tent fabric with lukewarm water and a non-detergent soap. " - "Allow it to air dry completely before storage and avoid prolonged UV " - "exposure to extend the lifespan of the waterproof coating." - ), - ) - ) - - if not results: - return - - context.extend_messages( - self.source_id, - [Message(role="user", contents=["\n\n".join(json.dumps(result.__dict__, indent=2) for result in results)])], - ) - - -def main(): - # Create an Agent using the Azure OpenAI Chat Client - agent = Agent( - client=FoundryChatClient(credential=DefaultAzureCredential()), - name="SupportSpecialist", - instructions=( - "You are a helpful support specialist for Contoso Outdoors. " - "Answer questions using the provided context and cite the source document when available." - ), - context_providers=[TextSearchContextProvider()], - ) - - # Run the agent as a hosted agent - from_agent_framework(agent).run() - - -if __name__ == "__main__": - main() diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt deleted file mode 100644 index d05845588a..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_text_search_rag/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-ai-agentserver-agentframework==1.0.0b3 -agent-framework \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml deleted file mode 100644 index 82f4d9e887..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/agent.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# Unique identifier/name for this agent -name: agents-in-workflow -# Brief description of what this agent does -description: > - A workflow agent that responds to product launch strategy inquiries by concurrently leveraging insights from three specialized agents. -metadata: - # Categorization tags for organizing and discovering agents - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Workflows -template: - name: agents-in-workflow - # The type of agent - "hosted" for HOBO, "container" for COBO - kind: hosted - protocols: - - protocol: responses - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_MODEL - value: "{{chat}}" -resources: - - kind: model - id: gpt-4o-mini - name: chat diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py deleted file mode 100644 index 414cfea418..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/main.py +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from agent_framework import Agent -from agent_framework.foundry import FoundryChatClient -from agent_framework_orchestrations import ConcurrentBuilder -from azure.ai.agentserver.agentframework import from_agent_framework -from azure.identity import DefaultAzureCredential # pyright: ignore[reportUnknownVariableType] -from dotenv import load_dotenv - -# Load environment variables from .env file -load_dotenv() - - -def main(): - # Create agents - researcher = Agent( - client=FoundryChatClient(credential=DefaultAzureCredential()), - instructions=( - "You're an expert market and product researcher. " - "Given a prompt, provide concise, factual insights, opportunities, and risks." - ), - name="researcher", - ) - marketer = Agent( - client=FoundryChatClient(credential=DefaultAzureCredential()), - instructions=( - "You're a creative marketing strategist. " - "Craft compelling value propositions and target messaging aligned to the prompt." - ), - name="marketer", - ) - legal = Agent( - client=FoundryChatClient(credential=DefaultAzureCredential()), - instructions=( - "You're a cautious legal/compliance reviewer. " - "Highlight constraints, disclaimers, and policy concerns based on the prompt." - ), - name="legal", - ) - - # Build a concurrent workflow - workflow = ConcurrentBuilder(participants=[researcher, marketer, legal]).build() - - # Convert the workflow to an agent - workflow_agent = Agent(client=workflow) - - # Run the agent as a hosted agent - from_agent_framework(workflow_agent).run() - - -if __name__ == "__main__": - main() diff --git a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt b/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt deleted file mode 100644 index d05845588a..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/agents_in_workflow/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-ai-agentserver-agentframework==1.0.0b3 -agent-framework \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore deleted file mode 100644 index bdb7cbb34f..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.dockerignore +++ /dev/null @@ -1,51 +0,0 @@ -# Build artifacts -bin/ -obj/ - -# IDE and editor files -.vs/ -.vscode/ -*.user -*.suo -.foundry/ - -# Source control -.git/ - -# Documentation -README.md - -# Ignore files -.gitignore -.dockerignore - -# Logs -*.log - -# Temporary files -*.tmp -*.temp - -# OS files -.DS_Store -Thumbs.db - -# Package manager directories -node_modules/ -packages/ - -# Test results -TestResults/ -*.trx - -# Coverage reports -coverage/ -*.coverage -*.coveragexml - -# Local development config -appsettings.Development.json -.env - -.venv/ -__pycache__/ diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample deleted file mode 100644 index 67bcea72f3..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/.env.sample +++ /dev/null @@ -1,3 +0,0 @@ -# IMPORTANT: Never commit .env to version control - add it to .gitignore -FOUNDRY_PROJECT_ENDPOINT= -FOUNDRY_MODEL= \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md deleted file mode 100644 index 8a9464f7b5..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/README.md +++ /dev/null @@ -1,157 +0,0 @@ -**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). - -Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. - -Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. - -Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. - -# What this sample demonstrates - -This sample demonstrates a **key advantage of code-based hosted agents**: - -- **Agents in Workflows** - Use AI agents as executors within a workflow pipeline - -Code-based agents can execute **any Python code** you write. This sample includes a multi-agent workflow where Writer and Reviewer agents collaborate to draft content and provide review feedback. - -The agent is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/) and can be deployed to Microsoft Foundry using the Azure Developer CLI. - -## How It Works - -### Agents in Workflows - -This sample demonstrates the integration of AI agents within a workflow pipeline. The workflow operates as follows: - -1. **Writer Agent** - Drafts content -2. **Reviewer Agent** - Reviews the draft and provides concise, actionable feedback - -### Agent Hosting - -The agent workflow is hosted using the [Azure AI AgentServer SDK](https://pypi.org/project/azure-ai-agentserver-agentframework/), -which provisions a REST API endpoint compatible with the OpenAI Responses protocol. - -### Agent Deployment - -The hosted agent workflow can be deployed to Microsoft Foundry using the Azure Developer CLI [ai agent](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli#create-a-hosted-agent) extension. - -## Running the Agent Locally - -### Prerequisites - -Before running this sample, ensure you have: - -1. **Microsoft Foundry Project** - - Project created in [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/what-is-foundry?view=foundry#microsoft-foundry-portals) - - Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`) - - Note your project endpoint URL and model deployment name - -2. **Azure CLI** - - Installed and authenticated - - Run `az login` and verify with `az account show` - -3. **Python 3.10 or higher** - - Verify your version: `python --version` - -### Environment Variables - -Set the following environment variables (matching `agent.yaml`): - -- `FOUNDRY_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `FOUNDRY_MODEL` - The deployment name for your chat model (defaults to `gpt-4.1-mini`) - -This sample loads environment variables from a local `.env` file if present. - -Create a `.env` file in this directory with the following content: - -``` -FOUNDRY_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ -FOUNDRY_MODEL=gpt-4.1-mini -``` - -Or set them via PowerShell: - -```powershell -# Replace with your actual values -$env:FOUNDRY_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:FOUNDRY_MODEL="gpt-4.1-mini" -``` - -### Running the Sample - -**Recommended (`uv`):** - -We recommend using [uv](https://docs.astral.sh/uv/) to create and manage the virtual environment for this sample. - -```bash -uv venv .venv -uv pip install --prerelease=allow -r requirements.txt -uv run main.py -``` - -The sample depends on preview packages, so `--prerelease=allow` is required when installing with `uv`. - -**Alternative (`venv`):** - -If you do not have `uv` installed, you can use Python's built-in `venv` module instead: - -**Windows (PowerShell):** - -```powershell -python -m venv .venv -.\.venv\Scripts\Activate.ps1 -pip install -r requirements.txt -python main.py -``` - -**macOS/Linux:** - -```bash -python -m venv .venv -source .venv/bin/activate -pip install -r requirements.txt -python main.py -``` - -This will start the hosted agent locally on `http://localhost:8088/`. - -### Interacting with the Agent - -**PowerShell (Windows):** - -```powershell -$body = @{ - input = "Create a slogan for a new electric SUV that is affordable and fun to drive." - stream = $false -} | ConvertTo-Json - -Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" -``` - -**Bash/curl (Linux/macOS):** - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ - -d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive.","stream":false}' -``` - -### Deploying the Agent to Microsoft Foundry - -To deploy your agent to Microsoft Foundry, follow the comprehensive deployment guide at https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents?view=foundry&tabs=cli - -## Troubleshooting - -### Images built on Apple Silicon or other ARM64 machines do not work on our service - -We **recommend using `azd` cloud build**, which always builds images with the correct architecture. - -If you choose to **build locally**, and your machine is **not `linux/amd64`** (for example, an Apple Silicon Mac), the image will **not be compatible with our service**, causing runtime failures. - -**Fix for local builds** - -Use this command to build the image locally: - -```shell -docker build --platform=linux/amd64 -t image . -``` - -This forces the image to be built for the required `amd64` architecture. diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml deleted file mode 100644 index 36e08b7e77..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/agent.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml - -kind: hosted -name: writer-reviewer-agents-in-workflow -description: > - A multi-agent workflow featuring a Writer and Reviewer that collaborate - to create and refine content. -metadata: - authors: - - Microsoft - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Multi-Agent Workflow - - Writer-Reviewer - - Content Creation -protocols: - - protocol: responses - version: v1 -environment_variables: - - name: FOUNDRY_PROJECT_ENDPOINT - value: ${FOUNDRY_PROJECT_ENDPOINT} - - name: FOUNDRY_MODEL - value: ${FOUNDRY_MODEL} \ No newline at end of file diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py deleted file mode 100644 index ff42f7d6dc..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/main.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import os -from contextlib import asynccontextmanager - -from agent_framework import Agent, WorkflowBuilder -from agent_framework.foundry import FoundryChatClient -from azure.ai.agentserver.agentframework import from_agent_framework -from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential -from dotenv import load_dotenv - -load_dotenv(override=True) - -# Configure these for your Foundry project -# Read the explicit variables present in the .env file -FOUNDRY_PROJECT_ENDPOINT = os.getenv( - "FOUNDRY_PROJECT_ENDPOINT" -) # e.g., "https://.services.ai.azure.com/api/projects/" -FOUNDRY_MODEL = os.getenv("FOUNDRY_MODEL", "gpt-4.1-mini") # Your model deployment name e.g., "gpt-4.1-mini" - - -def get_credential(): - """Will use Managed Identity when running in Azure, otherwise falls back to Azure CLI Credential.""" - return ManagedIdentityCredential() if os.getenv("MSI_ENDPOINT") else AzureCliCredential() - - -@asynccontextmanager -async def create_agents(): - async with get_credential() as credential: - client = FoundryChatClient( - project_endpoint=FOUNDRY_PROJECT_ENDPOINT, - model=FOUNDRY_MODEL, - credential=credential, - ) - writer = Agent( - client=client, - name="Writer", - instructions="You are an excellent content writer. You create new content and edit contents based on the feedback.", - ) - reviewer = Agent( - client=client, - name="Reviewer", - instructions="You are an excellent content reviewer. Provide actionable feedback to the writer about the provided content in the most concise manner possible.", - ) - yield writer, reviewer - - -def create_workflow(writer, reviewer): - workflow = WorkflowBuilder(start_executor=writer).add_edge(writer, reviewer).build() - return Agent( - client=workflow, - ) - - -async def main() -> None: - """ - The writer and reviewer multi-agent workflow. - - Environment variables required: - - FOUNDRY_PROJECT_ENDPOINT: Your Microsoft Foundry project endpoint - - FOUNDRY_MODEL: Your Microsoft Foundry model deployment name - """ - - async with create_agents() as (writer, reviewer): - agent = create_workflow(writer, reviewer) - await from_agent_framework(agent).run_async() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt b/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt deleted file mode 100644 index 515a4ec3f1..0000000000 --- a/python/samples/05-end-to-end/hosted_agents/writer_reviewer_agents_in_workflow/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -azure-ai-agentserver-agentframework==1.0.0b16 -agent-framework-foundry diff --git a/python/uv.lock b/python/uv.lock index 92fc51361d..fd6fcda64d 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -42,6 +42,7 @@ members = [ "agent-framework-devui", "agent-framework-durabletask", "agent-framework-foundry", + "agent-framework-foundry-hosting", "agent-framework-foundry-local", "agent-framework-gemini", "agent-framework-github-copilot", @@ -103,6 +104,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "azure-monitor-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "flit", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mypy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -127,6 +129,7 @@ requires-dist = [{ name = "agent-framework-core", extras = ["all"], editable = " [package.metadata.requires-dev] dev = [ + { name = "azure-monitor-opentelemetry", specifier = "==1.8.7" }, { name = "flit", specifier = "==3.12.0" }, { name = "mcp", extras = ["ws"], specifier = "==1.27.0" }, { name = "mypy", specifier = "==1.20.0" }, @@ -499,6 +502,25 @@ requires-dist = [ { name = "azure-ai-projects", specifier = ">=2.1.0,<3.0" }, ] +[[package]] +name = "agent-framework-foundry-hosting" +version = "1.0.0a260420" +source = { editable = "packages/foundry_hosting" } +dependencies = [ + { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-invocations", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-responses", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-framework-core", editable = "packages/core" }, + { name = "azure-ai-agentserver-core", specifier = "==2.0.0b2" }, + { name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b2" }, + { name = "azure-ai-agentserver-responses", specifier = "==1.0.0b4" }, +] + [[package]] name = "agent-framework-foundry-local" version = "1.0.0b260409" @@ -1005,6 +1027,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1032,6 +1066,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "azure-ai-agentserver-core" +version = "2.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "hypercorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/25/25865cfa76cbc20c18c4e9ed337456fd7374c01e930dd151463b4c183ac0/azure_ai_agentserver_core-2.0.0b2.tar.gz", hash = "sha256:cc6c90fdc4c2b2ce594f0e85288fda84910c04939d1427a64a485b2d48d6d684", size = 41605, upload-time = "2026-04-19T08:58:09.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/35/cf8a034f86d653fa902edb5ffa0a86005ea941f2840d2fa27302484856c1/azure_ai_agentserver_core-2.0.0b2-py3-none-any.whl", hash = "sha256:931e7a2d82275a01d7eb5ef08a70dba230938e3646be64c03d82749dd7be8afc", size = 27494, upload-time = "2026-04-19T08:58:10.588Z" }, +] + +[[package]] +name = "azure-ai-agentserver-invocations" +version = "1.0.0b2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/ef/11a161fa400f28390e9885854c434417fbd204ae006ca02b3a45ab285069/azure_ai_agentserver_invocations-1.0.0b2.tar.gz", hash = "sha256:cf352fd11b0057a2af28b1a921c84fb11f2fcbb9b4185cae9d93f2a45980227b", size = 30242, upload-time = "2026-04-19T09:43:31.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/f4/057206e0fca266b30ea68a531fa425078fd883500e779d5552858fe33d5b/azure_ai_agentserver_invocations-1.0.0b2-py3-none-any.whl", hash = "sha256:e799a9e6e54a10499296ee4f61720377fb31f540204832b654bac6f20e801597", size = 11432, upload-time = "2026-04-19T09:43:32.744Z" }, +] + +[[package]] +name = "azure-ai-agentserver-responses" +version = "1.0.0b4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/01/614dafa9366a5bdfe50ec112b15faa57e32a96866796bc2812ba329f4fec/azure_ai_agentserver_responses-1.0.0b4.tar.gz", hash = "sha256:2fa69db26ff52d8d2cd667a1461675e5124aabf8f268b842402e36f50d6c7176", size = 397007, upload-time = "2026-04-20T07:33:18.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/bd/c56df7c9257f10014ae1cd161ac08784bd9fe682233ab1a987c98b5b78c0/azure_ai_agentserver_responses-1.0.0b4-py3-none-any.whl", hash = "sha256:7684c6bef57bdcd1941cce2d6b5e2ea07edd7ce9f90e84f171804cc728b60fcc", size = 263375, upload-time = "2026-04-20T07:33:19.956Z" }, +] + [[package]] name = "azure-ai-inference" version = "1.0.0b9" @@ -1085,6 +1163,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl", hash = "sha256:4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f", size = 218318, upload-time = "2026-03-19T01:31:31.25Z" }, ] +[[package]] +name = "azure-core-tracing-opentelemetry" +version = "1.0.0b12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/7f/5de13a331a5f2919417819cc37dcf7c897018f02f83aa82b733e6629a6a6/azure_core_tracing_opentelemetry-1.0.0b12.tar.gz", hash = "sha256:bb454142440bae11fd9d68c7c1d67ae38a1756ce808c5e4d736730a7b4b04144", size = 26010, upload-time = "2025-03-21T00:18:37.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/5e/97a471f66935e7f89f521d0e11ae49c7f0871ca38f5c319dccae2155c8d8/azure_core_tracing_opentelemetry-1.0.0b12-py3-none-any.whl", hash = "sha256:38fd42709f1cc4bbc4f2797008b1c30a6a01617e49910c05daa3a0d0c65053ac", size = 11962, upload-time = "2025-03-21T00:18:38.581Z" }, +] + [[package]] name = "azure-cosmos" version = "4.15.0" @@ -1144,6 +1235,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, ] +[[package]] +name = "azure-monitor-opentelemetry" +version = "1.8.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-core-tracing-opentelemetry", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-django", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-flask", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-logging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-psycopg2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-resource-detector-azure", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/42/ea67bebb400a7561b1ad1dd59d06b67e880daf8081ec0d41d3b0ce8fcc26/azure_monitor_opentelemetry-1.8.7.tar.gz", hash = "sha256:d0a430c69451f8fa09362769d2d65471713989fb78e4ad0f50832b597921efbb", size = 76970, upload-time = "2026-03-19T21:43:57.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/22/245a4f75a834430759a6fab9c5ab10e18719786ae684cf234c7bb6a693d1/azure_monitor_opentelemetry-1.8.7-py3-none-any.whl", hash = "sha256:0d3a228a183d76cf22698a3eed6e836d1cf57608b8ee879c634609b26f384eb2", size = 41268, upload-time = "2026-03-19T21:43:58.188Z" }, +] + +[[package]] +name = "azure-monitor-opentelemetry-exporter" +version = "1.0.0b51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "azure-identity", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "msrest", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "psutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/a4/a6cd2d389bc1009300bcd57c9e2ace4b7e7ae1e5dc0bda415ee803629cf2/azure_monitor_opentelemetry_exporter-1.0.0b51.tar.gz", hash = "sha256:a6171c34326bcd6216938bb40d715c15f1f22984ac1986fc97231336d8ac4c3c", size = 319837, upload-time = "2026-04-06T21:45:46.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/1a/6b0b7a6181b42709103a65a676c89fd5055cb1d1b281ebe10c49254a170f/azure_monitor_opentelemetry_exporter-1.0.0b51-py2.py3-none-any.whl", hash = "sha256:6572cac11f96e3b18ae1187cb35cf3b40d0004655dae8048896c41c765bea530", size = 242104, upload-time = "2026-04-06T21:45:47.856Z" }, +] + [[package]] name = "azure-search-documents" version = "11.7.0b2" @@ -2730,6 +2862,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ae/8a3a16ea4d202cb641b51d2681bdd3d482c1c592d7570b3fa264730829ce/huggingface_hub-1.8.0-py3-none-any.whl", hash = "sha256:d3eb5047bd4e33c987429de6020d4810d38a5bef95b3b40df9b17346b7f353f2", size = 625208, upload-time = "2026-03-25T16:01:26.603Z" }, ] +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "h2", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "priority", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "taskgroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "tomli", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "wsproto", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + [[package]] name = "hyperframe" version = "6.1.0" @@ -3699,6 +3850,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "certifi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests-oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -4246,6 +4413,174 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, ] +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-dbapi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/ed/ba91c9e4a3ec65781e9c59982109f0a36de9fa574f622596b33d1985dab5/opentelemetry_instrumentation_dbapi-0.61b0.tar.gz", hash = "sha256:02fa800682c1de87dcad0e59f2092b3b6fb8b8ea0636518f989e1166b418dcb9", size = 16761, upload-time = "2026-03-04T14:20:29.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/a5/d26c68f3fd33eb7410985cef7700bb426e2c4a26de9207902cbbffb19a3f/opentelemetry_instrumentation_dbapi-0.61b0-py3-none-any.whl", hash = "sha256:8f762c39c8edd20c6aef3282550a2cfbfec76c3f431bf5c36327dcf9ece2e5a0", size = 14134, upload-time = "2026-03-04T14:19:24.718Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-django" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/ef/6bc1a6560630f26b1c010af86b28f42bfbe6a601bd1647d1436e0d3436aa/opentelemetry_instrumentation_django-0.61b0.tar.gz", hash = "sha256:9885154dc128578de0e6b5ce49e965c786f8ab071175bec005dcd454510be951", size = 25996, upload-time = "2026-03-04T14:20:30.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/3b/74dad6d98fdee1d137f1c2748548d4159578508f21e3aef581c110e64041/opentelemetry_instrumentation_django-0.61b0-py3-none-any.whl", hash = "sha256:26c1b0b325a9783d4a2f4df660ba05cf929c3eda2ae9b07916b649bb44e1c5b6", size = 20773, upload-time = "2026-03-04T14:19:25.675Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-asgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-flask" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-wsgi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/33/d6852d8f2c3eef86f2f8c858d6f5315983c7063e07e595519e96d4c31c06/opentelemetry_instrumentation_flask-0.61b0.tar.gz", hash = "sha256:e9faf58dfd9860a1868442d180142645abdafc1a652dd73d469a5efd106a7d49", size = 24071, upload-time = "2026-03-04T14:20:33.437Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/41/619f3530324a58491f2d20f216a10dd7393629b29db4610dda642a27f4ed/opentelemetry_instrumentation_flask-0.61b0-py3-none-any.whl", hash = "sha256:e8ce474d7ce543bfbbb3e93f8a6f8263348af9d7b45502f387420cf3afa71253", size = 15996, upload-time = "2026-03-04T14:19:31.304Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-logging" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/69473f925acfe2d4edf5c23bcced36906ac3627aa7c5722a8e3f60825f3b/opentelemetry_instrumentation_logging-0.61b0.tar.gz", hash = "sha256:feaa30b700acd2a37cc81db5f562ab0c3a5b6cc2453595e98b72c01dcf649584", size = 17906, upload-time = "2026-03-04T14:20:37.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/0e/2137db5239cc5e564495549a4d11488a7af9b48fc76520a0eea20e69ddae/opentelemetry_instrumentation_logging-0.61b0-py3-none-any.whl", hash = "sha256:6d87e5ded6a0128d775d41511f8380910a1b610671081d16efb05ac3711c0074", size = 17076, upload-time = "2026-03-04T14:19:36.765Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-psycopg2" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation-dbapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/28/f28d52b1088e7a09761566f8700507b54d3d83a6f9c93c0ce02f53619e83/opentelemetry_instrumentation_psycopg2-0.61b0.tar.gz", hash = "sha256:863ccf9687b71e73dd489c7bb117278768bdf26aa0dafe7dc974a2425e05b5d7", size = 11676, upload-time = "2026-03-04T14:20:41.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f1/4341d0584c288765c73e28c30ba58e7aedb50c01108f17f947b872657f79/opentelemetry_instrumentation_psycopg2-0.61b0-py3-none-any.whl", hash = "sha256:36b96983beda05c927179bb66b6c72f07a8d9a591f76ce9da88b1dd1587cb083", size = 11491, upload-time = "2026-03-04T14:19:42.018Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-requests" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/c7/7a47cb85c7aa93a9c820552e414889185bcf91245271d12e5d443e5f834d/opentelemetry_instrumentation_requests-0.61b0.tar.gz", hash = "sha256:15f879ce8fb206bd7e6fdc61663ea63481040a845218c0cf42902ce70bd7e9d9", size = 18379, upload-time = "2026-03-04T14:20:46.959Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/a1/a7a133b273d1f53950f16a370fc94367eff472c9c2576e8e9e28c62dcc9f/opentelemetry_instrumentation_requests-0.61b0-py3-none-any.whl", hash = "sha256:cce19b379949fe637eb73ba39b02c57d2d0805447ca6d86534aa33fcb141f683", size = 14207, upload-time = "2026-03-04T14:19:51.765Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/37/77cd326b083390e74280c08bbd585153809619dad068e2d1b253fec1164d/opentelemetry_instrumentation_urllib-0.61b0.tar.gz", hash = "sha256:6a15ff862fc1603e0ea5ea75558f76f36436b02e0ae48daecedcb5e574cce160", size = 16894, upload-time = "2026-03-04T14:20:52.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/fc/a88fbfd8b9eb16ba1c21f0514c12696441be7fc42c7e319f3ee793bf9e96/opentelemetry_instrumentation_urllib-0.61b0-py3-none-any.whl", hash = "sha256:d7e409876580fb41102e3522ce81a756e53a74073c036a267a1c280cc0fa09b0", size = 13970, upload-time = "2026-03-04T14:20:01.24Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-urllib3" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/80/7ad8da30f479c6117768e72d6f2f3f0bd3495338707d6f61de042149578a/opentelemetry_instrumentation_urllib3-0.61b0.tar.gz", hash = "sha256:f00037bc8ff813153c4b79306f55a14618c40469a69c6c03a3add29dc7e8b928", size = 19325, upload-time = "2026-03-04T14:20:53.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/0c/01359e55b9f2fb2b1d4d9e85e77773a96697207895118533f3be718a3326/opentelemetry_instrumentation_urllib3-0.61b0-py3-none-any.whl", hash = "sha256:9644f8c07870266e52f129e6226859ff3a35192555abe46fa0ef9bbbf5b6b46d", size = 14339, upload-time = "2026-03-04T14:20:02.681Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-wsgi" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-instrumentation", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-semantic-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "opentelemetry-util-http", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/e5/189f2845362cfe78e356ba127eab21456309def411c6874aa4800c3de816/opentelemetry_instrumentation_wsgi-0.61b0.tar.gz", hash = "sha256:380f2ae61714e5303275a80b2e14c58571573cd1fddf496d8c39fb9551c5e532", size = 19898, upload-time = "2026-03-04T14:20:54.068Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/75/d6b42ba26f3c921be6d01b16561b7bb863f843bad7ac3a5011f62617bcab/opentelemetry_instrumentation_wsgi-0.61b0-py3-none-any.whl", hash = "sha256:bd33b0824166f24134a3400648805e8d2e6a7951f070241294e8b8866611d7fa", size = 14628, upload-time = "2026-03-04T14:20:03.934Z" }, +] + [[package]] name = "opentelemetry-proto" version = "1.40.0" @@ -4258,6 +4593,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" }, ] +[[package]] +name = "opentelemetry-resource-detector-azure" +version = "0.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e4/0d359d48d03d447225b30c3dd889d5d454e3b413763ff721f9b0e4ac2e59/opentelemetry_resource_detector_azure-0.1.5.tar.gz", hash = "sha256:e0ba658a87c69eebc806e75398cd0e9f68a8898ea62de99bc1b7083136403710", size = 11503, upload-time = "2024-05-16T21:54:58.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/c26d8da88ba2e438e9653a408b0c2ad6f17267801250a8f3cc6405a93a72/opentelemetry_resource_detector_azure-0.1.5-py3-none-any.whl", hash = "sha256:4dcc5d54ab5c3b11226af39509bc98979a8b9e0f8a24c1b888783755d3bf00eb", size = 14252, upload-time = "2024-05-16T21:54:57.208Z" }, +] + [[package]] name = "opentelemetry-sdk" version = "1.40.0" @@ -4285,6 +4632,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, ] +[[package]] +name = "opentelemetry-util-http" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" }, +] + [[package]] name = "ordered-set" version = "4.1.0" @@ -4800,6 +5156,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/53/05/9cca1708bb8c65264124eb4b04251e0f65ce5bfc707080bb6b492d5a0df7/prek-0.3.8-py3-none-win_arm64.whl", hash = "sha256:a2614647aeafa817a5802ccb9561e92eedc20dcf840639a1b00826e2c2442515", size = 5190872, upload-time = "2026-03-23T08:23:29.463Z" }, ] +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, +] + [[package]] name = "propcache" version = "0.4.1" @@ -5739,6 +6104,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "rich" version = "13.9.4" @@ -6446,6 +6824,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "taskgroup" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/8d/e218e0160cc1b692e6e0e5ba34e8865dbb171efeb5fc9a704544b3020605/taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d", size = 11504, upload-time = "2025-01-03T09:24:13.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/b1/74babcc824a57904e919f3af16d86c08b524c0691504baf038ef2d7f655c/taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb", size = 14237, upload-time = "2025-01-03T09:24:11.41Z" }, +] + [[package]] name = "tau2" version = "0.0.1" @@ -7145,6 +7536,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, +] + [[package]] name = "yarl" version = "1.23.0" From 2c8036779c20e5fa2feb6304c01e28c594e801a9 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:14:42 +0900 Subject: [PATCH 12/52] Python: Bump versions for a release. Update CHANGELOG (#5385) * Bump versions for a release. Update CHANGELOG * Bump devui --- python/CHANGELOG.md | 39 ++++++++++++- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry/pyproject.toml | 6 +- .../packages/foundry_hosting/pyproject.toml | 4 +- python/packages/foundry_local/pyproject.toml | 6 +- python/packages/gemini/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- python/packages/hyperlight/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/openai/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 56 +++++++++---------- 30 files changed, 123 insertions(+), 86 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 4adafae53c..3579049fc8 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,8 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.0] - 2026-04-21 + +### Added +- **agent-framework-gemini**: Add `GeminiChatClient` ([#4847](https://github.com/microsoft/agent-framework/pull/4847)) +- **agent-framework-core**: Add `context_providers` and `description` to `workflow.as_agent()` ([#4651](https://github.com/microsoft/agent-framework/pull/4651)) +- **agent-framework-core**: Add experimental file history provider ([#5248](https://github.com/microsoft/agent-framework/pull/5248)) +- **agent-framework-core**: Add OpenAI types to the default checkpoint encoding allow list ([#5297](https://github.com/microsoft/agent-framework/pull/5297)) +- **agent-framework-core**: Add `AgentExecutorResponse.with_text()` to preserve conversation history through custom executors ([#5255](https://github.com/microsoft/agent-framework/pull/5255)) +- **agent-framework-a2a**: Propagate A2A metadata from `Message`, `Artifact`, `Task`, and event types ([#5256](https://github.com/microsoft/agent-framework/pull/5256)) +- **agent-framework-core**: Add `finish_reason` support to `AgentResponse` and `AgentResponseUpdate` ([#5211](https://github.com/microsoft/agent-framework/pull/5211)) +- **agent-framework-hyperlight**: Add Hyperlight CodeAct package and docs ([#5185](https://github.com/microsoft/agent-framework/pull/5185)) +- **agent-framework-openai**: Add search tool content support for OpenAI responses ([#5302](https://github.com/microsoft/agent-framework/pull/5302)) +- **agent-framework-foundry**: Add support for Foundry Toolboxes ([#5346](https://github.com/microsoft/agent-framework/pull/5346)) +- **agent-framework-ag-ui**: Expose `forwardedProps` to agents and tools via session metadata ([#5264](https://github.com/microsoft/agent-framework/pull/5264)) +- **agent-framework-foundry**: Add hosted agent V2 support ([#5379](https://github.com/microsoft/agent-framework/pull/5379)) + ### Changed - **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200)) +- **agent-framework-core**: Improve skill name validation ([#4530](https://github.com/microsoft/agent-framework/pull/4530)) +- **agent-framework-azure-cosmos**: Add `allowed_checkpoint_types` support to `CosmosCheckpointStorage` for parity with `FileCheckpointStorage` ([#5202](https://github.com/microsoft/agent-framework/pull/5202)) +- **agent-framework-core**: Move `InMemory` history provider injection to first invocation ([#5236](https://github.com/microsoft/agent-framework/pull/5236)) +- **agent-framework-github-copilot**: Forward provider config to `SessionConfig` in `GitHubCopilotAgent` ([#5195](https://github.com/microsoft/agent-framework/pull/5195)) +- **agent-framework-hyperlight-codeact**: Flatten `execute_code` output ([#5333](https://github.com/microsoft/agent-framework/pull/5333)) +- **dependencies**: Bump `pygments` from `2.19.2` to `2.20.0` in `/python` ([#4978](https://github.com/microsoft/agent-framework/pull/4978)) +- **tests**: Bump misc integration retry delay to 30s ([#5293](https://github.com/microsoft/agent-framework/pull/5293)) +- **tests**: Improve misc integration test robustness ([#5295](https://github.com/microsoft/agent-framework/pull/5295)) +- **tests**: Skip hosted tools test on transient upstream MCP errors ([#5296](https://github.com/microsoft/agent-framework/pull/5296)) + +### Fixed +- **agent-framework-core**: Fix `python-feature-lifecycle` skill YAML frontmatter ([#5226](https://github.com/microsoft/agent-framework/pull/5226)) +- **agent-framework-core**: Fix `HandoffBuilder` dropping function-level middleware when cloning agents ([#5220](https://github.com/microsoft/agent-framework/pull/5220)) +- **agent-framework-ag-ui**: Fix deterministic state updates from tool results ([#5201](https://github.com/microsoft/agent-framework/pull/5201)) +- **agent-framework-devui**: Fix streaming memory growth and add cross-platform regression coverage ([#5221](https://github.com/microsoft/agent-framework/pull/5221)) +- **agent-framework-core**: Skip `get_final_response` in `_finalize_stream` when the stream has errored ([#5232](https://github.com/microsoft/agent-framework/pull/5232)) +- **agent-framework-openai**: Fix reasoning replay when `store=False` ([#5250](https://github.com/microsoft/agent-framework/pull/5250)) +- **agent-framework-foundry**: Handle `url_citation` annotations in `FoundryChatClient` streaming responses ([#5071](https://github.com/microsoft/agent-framework/pull/5071)) +- **agent-framework-gemini**: Fix Gemini client support for Gemini API and Vertex AI ([#5258](https://github.com/microsoft/agent-framework/pull/5258)) +- **agent-framework-copilotstudio**: Fix `CopilotStudioAgent` to reuse conversation ID from an existing session ([#5299](https://github.com/microsoft/agent-framework/pull/5299)) ## [devui-1.0.0b260414] - 2026-04-14 @@ -903,7 +939,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.0.1...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD +[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0 [1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1 [1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 [1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index 787016a287..d21bed0558 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index abe17daea9..8eb76406af 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index f162cc5d06..e1f884a751 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 15cc466b20..63baf70852 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 4193b07014..cf55a9d8c5 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 8445c08ed9..611329e9e5 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 66996ad9ba..9c46c058a9 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index ae0a795e0d..c706c6ec22 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 4b2e6059fc..b71b662954 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 90bb6b107f..2f42515a46 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index e28245d6e7..fec81c2193 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -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.0.1" +version = "1.1.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 9018b2676b..6b3d0a9d36 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 65a595eb48..023d4f5d51 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -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.0b260414" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index 8e95b3920f..50d650a21a 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 67feb98c98..d95ed6c13c 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -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.0.1" +version = "1.1.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.0.1,<2", - "agent-framework-openai>=1.0.1,<2", + "agent-framework-core>=1.1.0,<2", + "agent-framework-openai>=1.1.0,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.1.0,<3.0", ] diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index 3078e018d2..fbee988947 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -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.0a260420" +version = "1.0.0a260421" 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.0.0,<2", + "agent-framework-core>=1.1.0,<2", "azure-ai-agentserver-core==2.0.0b2", "azure-ai-agentserver-responses==1.0.0b4", "azure-ai-agentserver-invocations==1.0.0b2", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 3002cd0d69..cf9001ff3a 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", - "agent-framework-openai>=1.0.1,<2", + "agent-framework-core>=1.1.0,<2", + "agent-framework-openai>=1.1.0,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index bae8b4ac1a..66cef70fad 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -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.0a260410" +version = "1.0.0a260421" 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.0.0,<2.0", + "agent-framework-core>=1.1.0,<2.0", "google-genai>=1.0.0,<2.0.0", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 90766cb7f6..287d5aec86 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 21034b1a8e..bf898ce4dd 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -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.0a260409" +version = "1.0.0a260421" 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.0.0,<2", + "agent-framework-core>=1.1.0,<2", "hyperlight-sandbox>=0.3.0,<0.4", "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((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.3.0,<0.4", diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 10e98810d5..d1c7f175a1 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 20c8a6d467..7712be304e 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index bf2b93cfaa..a0f1d6316e 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 22b59e5a43..2ea7b3eee1 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -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.0.1" +version = "1.1.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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 9d2232fb4a..d808388258 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 711e0cbaf6..257f8df401 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 533ed6635d..838e7fd968 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -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.0b260409" +version = "1.0.0b260421" 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.0.1,<2", + "agent-framework-core>=1.1.0,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index b1e455719f..0d5dac2a0b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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.0.1" +version = "1.1.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[all]==1.0.1", + "agent-framework-core[all]==1.1.0", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index fd6fcda64d..73c18a6375 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.1" +version = "1.1.0" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -151,7 +151,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -166,7 +166,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -194,7 +194,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -209,7 +209,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -224,7 +224,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -239,7 +239,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -261,7 +261,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -278,7 +278,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -293,7 +293,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -323,7 +323,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.0.1" +version = "1.1.0" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -395,7 +395,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -420,7 +420,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260414" +version = "1.0.0b260421" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -458,7 +458,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -485,7 +485,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.0.1" +version = "1.1.0" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -504,7 +504,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260420" +version = "1.0.0a260421" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -523,7 +523,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -540,7 +540,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260410" +version = "1.0.0a260421" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -555,7 +555,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -570,7 +570,7 @@ requires-dist = [ [[package]] name = "agent-framework-hyperlight" -version = "1.0.0a260409" +version = "1.0.0a260421" source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -589,7 +589,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -670,7 +670,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -685,7 +685,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -700,7 +700,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.0.1" +version = "1.1.0" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -715,7 +715,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -726,7 +726,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -743,7 +743,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260409" +version = "1.0.0b260421" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From b6b191ad9c2ddaaa8a647419135f01a2d3fce73a Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Tue, 21 Apr 2026 16:08:50 +0900 Subject: [PATCH 13/52] Python: Add second approval-required tool (set_stop_loss) to concurrent_builder_tool_approval sample (#4875) * Add set_stop_loss tool to concurrent_builder_tool_approval sample Add a second approval-gated tool (set_stop_loss) to the concurrent workflow tool approval sample to demonstrate handling approval requests for different tools in the same concurrent workflow. Changes: - Add set_stop_loss(symbol, stop_price) with approval_mode='always_require' - Include new tool in both agents' tool lists - Update agent instructions and prompt to encourage stop-loss usage - Update docstring to reflect two approval-gated tools - Update sample output to show mixed approval requests Fixes #4874 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Print tool name and arguments in concurrent sample's process_event_stream (#4874) Align process_event_stream in concurrent_builder_tool_approval.py to print the tool name and arguments when collecting approval requests, matching the sample output comment and the sequential_builder_tool_approval.py pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add None-guard for function_call access in tool approval sample (#4874) Add explicit None-checks before accessing function_call.name and function_call.arguments in concurrent_builder_tool_approval.py. The function_call field is typed Content | None, so direct attribute access without a guard could raise AttributeError and required type: ignore comments. The None-guard is consistent with the pattern used in _agent_run.py and removes the suppression comments. Also add a regression test verifying that function_call defaults to None and that the None-guard pattern is safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply same function_call None-guard to sibling tool-approval samples (#4874) Apply the same fix to sequential_builder_tool_approval.py and group_chat_builder_tool_approval.py, which had the identical pattern of accessing function_call.name/arguments without a None-guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/tests/core/test_types.py | 15 +++++ .../concurrent_builder_tool_approval.py | 61 +++++++++++++------ .../group_chat_builder_tool_approval.py | 8 +-- .../sequential_builder_tool_approval.py | 8 +-- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 4298563209..4f6060426a 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -664,6 +664,21 @@ def test_function_approval_serialization_roundtrip(): # The Content union will need to be handled differently when we fully migrate +def test_function_approval_request_function_call_none_guard(): + """Test that accessing function_call attributes is safe when function_call is None.""" + # Construct a Content with type "function_approval_request" but no function_call. + # This verifies the None-guard pattern used in samples to prevent AttributeError. + content = Content("function_approval_request", id="req-none") + assert content.function_call is None + + # A proper approval request always has function_call set + fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1}) + req = Content.from_function_approval_request(id="req-1", function_call=fc) + assert req.function_call is not None + assert req.function_call.name == "do_something" + assert req.function_call.arguments == {"a": 1} + + def test_function_approval_accepts_mcp_call(): """Ensure FunctionApprovalRequestContent supports MCP server tool calls.""" mcp_call = Content.from_mcp_server_tool_call( diff --git a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py index b9a0e7d229..d3eeea0a9b 100644 --- a/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/concurrent_builder_tool_approval.py @@ -29,19 +29,19 @@ approval will pause the workflow until the human responds. This sample works as follows: 1. A ConcurrentBuilder workflow is created with two agents running in parallel. -2. Both agents have the same tools, including one requiring approval (execute_trade). +2. Both agents have the same tools, including two requiring approval (execute_trade, set_stop_loss). 3. Both agents receive the same task and work concurrently on their respective stocks. -4. When either agent tries to execute a trade, it triggers an approval request. +4. When either agent tries to execute a trade or set a stop-loss, it triggers an approval request. 5. The sample simulates human approval and the workflow completes. 6. Results from both agents are aggregated and output. Purpose: Show how tool call approvals work in parallel execution scenarios where multiple -agents may independently trigger approval requests. +agents may independently trigger approval requests for different tools. Demonstrate: - Handling multiple approval requests from different agents in concurrent workflows. -- Handling during concurrent agent execution. +- Handling approval requests for different tools during concurrent agent execution. - Understanding that approval pauses only the agent that triggered it, not all agents. Prerequisites: @@ -89,6 +89,15 @@ def execute_trade( return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}" +@tool(approval_mode="always_require") +def set_stop_loss( + symbol: Annotated[str, "The stock ticker symbol"], + stop_price: Annotated[float, "The stop-loss price"], +) -> str: + """Set a stop-loss order for a stock. Requires human approval due to financial impact.""" + return f"Stop-loss set for {symbol.upper()} at ${stop_price:.2f}" + + @tool(approval_mode="never_require") def get_portfolio_balance() -> str: """Get current portfolio balance and available funds.""" @@ -118,14 +127,17 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str if event.type == "request_info" and isinstance(event.data, Content): # We are only expecting tool approval requests in this sample requests[event.request_id] = event.data + if event.data.type == "function_approval_request" and event.data.function_call is not None: + print(f"\nApproval requested for tool: {event.data.function_call.name}") + print(f"Arguments: {event.data.function_call.arguments}") elif event.type == "output": _print_output(event) responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": - print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore + if request.type == "function_approval_request" and request.function_call is not None: + print(f"\nSimulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True) @@ -145,9 +157,10 @@ async def main() -> None: name="MicrosoftAgent", instructions=( "You are a personal trading assistant focused on Microsoft (MSFT). " - "You manage my portfolio and take actions based on market data." + "You manage my portfolio and take actions based on market data. " + "Use stop-loss orders to manage risk." ), - tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], + tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss], ) google_agent = Agent( @@ -155,9 +168,10 @@ async def main() -> None: name="GoogleAgent", instructions=( "You are a personal trading assistant focused on Google (GOOGL). " - "You manage my trades and portfolio based on market conditions." + "You manage my trades and portfolio based on market conditions. " + "Use stop-loss orders to manage risk." ), - tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade], + tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss], ) # 4. Build a concurrent workflow with both agents @@ -172,7 +186,8 @@ async def main() -> None: # Runs are not isolated; state is preserved across multiple calls to run. stream = workflow.run( "Manage my portfolio. Use a max of 5000 dollars to adjust my position using " - "your best judgment based on market sentiment. No need to confirm trades with me.", + "your best judgment based on market sentiment. Set stop-loss orders to manage risk. " + "No need to confirm trades with me.", stream=True, ) @@ -191,22 +206,32 @@ async def main() -> None: Approval requested for tool: execute_trade Arguments: {"symbol":"MSFT","action":"buy","quantity":13} + Approval requested for tool: set_stop_loss + Arguments: {"symbol":"MSFT","stop_price":340.0} + Approval requested for tool: execute_trade Arguments: {"symbol":"GOOGL","action":"buy","quantity":35} - Simulating human approval for: execute_trade + Approval requested for tool: set_stop_loss + Arguments: {"symbol":"GOOGL","stop_price":126.0} Simulating human approval for: execute_trade + Simulating human approval for: set_stop_loss + + Simulating human approval for: execute_trade + + Simulating human approval for: set_stop_loss + ------------------------------------------------------------ Workflow completed. Aggregated results from both agents: - user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on - market sentiment. No need to confirm trades with me. - - MicrosoftAgent: I have successfully executed the trade, purchasing 13 shares of Microsoft (MSFT). This action - was based on the positive market sentiment and available funds within the specified limit. - Your portfolio has been adjusted accordingly. - - GoogleAgent: I have successfully executed the trade, purchasing 35 shares of GOOGL. If you need further - assistance or any adjustments, feel free to ask! + market sentiment. Set stop-loss orders to manage risk. No need to confirm trades with me. + - MicrosoftAgent: I have successfully purchased 13 shares of Microsoft (MSFT) and set a stop-loss at $340.00. + This action was based on the positive market sentiment and available funds within the + specified limit. Your portfolio has been adjusted accordingly. + - GoogleAgent: I have successfully purchased 35 shares of GOOGL and set a stop-loss at $126.00. If you need + further assistance or any adjustments, feel free to ask! """ diff --git a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py index 371ff20294..6d0612e838 100644 --- a/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/group_chat_builder_tool_approval.py @@ -121,11 +121,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": + if request.type == "function_approval_request" and request.function_call is not None: print("\n[APPROVAL REQUIRED]") - print(f" Tool: {request.function_call.name}") # type: ignore - print(f" Arguments: {request.function_call.arguments}") # type: ignore - print(f"Simulating human approval for: {request.function_call.name}") # type: ignore + print(f" Tool: {request.function_call.name}") + print(f" Arguments: {request.function_call.arguments}") + print(f"Simulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True) diff --git a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py index 318506316e..7e95564d44 100644 --- a/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py +++ b/python/samples/03-workflows/tool-approval/sequential_builder_tool_approval.py @@ -94,11 +94,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str responses: dict[str, Content] = {} if requests: for request_id, request in requests.items(): - if request.type == "function_approval_request": + if request.type == "function_approval_request" and request.function_call is not None: print("\n[APPROVAL REQUIRED]") - print(f" Tool: {request.function_call.name}") # type: ignore - print(f" Arguments: {request.function_call.arguments}") # type: ignore - print(f"Simulating human approval for: {request.function_call.name}") # type: ignore + print(f" Tool: {request.function_call.name}") + print(f" Arguments: {request.function_call.arguments}") + print(f"Simulating human approval for: {request.function_call.name}") # Create approval response responses[request_id] = request.to_function_approval_response(approved=True) From d5777bc546ba48652d85cec6093b445965533a4a Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 21 Apr 2026 04:14:35 -0400 Subject: [PATCH 14/52] fix: Duplicate CallIds cause Handoff Message Filtering to fail (#5359) Some providers, e.g. Gemini, do not use the CallId mechanism to disambiguate simultaneous function calls. This can result in message lists containing multiple turn to fail to filter properly. The fix is to take advantage of the expectation that Handoff Orchestration is a "single-speaker" flow, which only has a single active AIAgent per "turn" and an agent's turn is not finished until all outstanding function calls are finished. This allows us to expect that any ambiguous-CallId FunctionCallContent are either in separate turns or will have had a response before the next issued call with the same Id. --- .../Specialized/HandoffAgentExecutor.cs | 4 +- .../Specialized/HandoffMessagesFilter.cs | 144 +++++++----------- .../HandoffMessageFilterTests.cs | 115 ++++++++++++++ 3 files changed, 172 insertions(+), 91 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffMessageFilterTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index d9acab96d5..576c749a90 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -426,7 +426,7 @@ internal sealed class HandoffAgentExecutor : { AgentId = this._agent.Id, AuthorName = this._agent.Name ?? this._agent.Id, - Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")], + Contents = [CreateHandoffResult(handoffRequest.CallId)], CreatedAt = DateTimeOffset.UtcNow, MessageId = Guid.NewGuid().ToString("N"), Role = ChatRole.Tool, @@ -459,4 +459,6 @@ internal sealed class HandoffAgentExecutor : ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null : null; } + + internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred."); } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs index 7bc178c2d4..61eebc0e2b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffMessagesFilter.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.Linq; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized; @@ -31,113 +30,78 @@ internal sealed class HandoffMessagesFilter return messages; } - Dictionary filteringCandidates = new(); - List filteredMessages = []; - HashSet messagesToRemove = []; + HashSet filteredCallsWithoutResponses = new(); + List retainedMessages = []; + + bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All; + + // The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent. + // We are going to assume that Handoff operates as follows: + // * Each agent is only taking one turn at a time + // * Each agent is taking a turn alone + // + // In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the + // call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another + // "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream. + // (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering). + // + // The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered + // content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed. - bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly; foreach (ChatMessage unfilteredMessage in messages) { - ChatMessage filteredMessage = unfilteredMessage.Clone(); - - // .Clone() is shallow, so we cannot modify the contents of the cloned message in place. - List contents = []; - contents.Capacity = unfilteredMessage.Contents?.Count ?? 0; - filteredMessage.Contents = contents; - - // Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls - // originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result) - // FunctionCallContent. - if (unfilteredMessage.Role != ChatRole.Tool) + if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0) { - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) - { - AIContent content = unfilteredMessage.Contents[i]; - if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name))) - { - filteredMessage.Contents.Add(content); - - // Track non-handoff function calls so their tool results are preserved in HandoffOnly mode - if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc) - { - filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId) - { - IsHandoffFunction = false, - }; - } - } - else if (filterHandoffOnly) - { - if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState)) - { - filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId) - { - IsHandoffFunction = true, - }; - } - else - { - candidateState.IsHandoffFunction = true; - (int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value; - ChatMessage messageToFilter = filteredMessages[messageIndex]; - messageToFilter.Contents.RemoveAt(contentIndex); - if (messageToFilter.Contents.Count == 0) - { - messagesToRemove.Add(messageIndex); - } - } - } - else - { - // All mode: strip all FunctionCallContent - } - } + retainedMessages.Add(unfilteredMessage); + continue; } - else + + // We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list + // of AIContent which we will stuff into a clone of the message if we need to filter out any content. + List retainedContents = new(capacity: unfilteredMessage.Contents.Count); + + foreach (AIContent content in unfilteredMessage.Contents) { - if (!filterHandoffOnly) + if (content is FunctionCallContent fcc + && (filterAllToolCalls || IsHandoffFunctionName(fcc.Name))) { + // If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC, + // which violates our assumption of strict ordering. + if (!filteredCallsWithoutResponses.Add(fcc.CallId)) + { + throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent."); + } + + // If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then + // filter this FCC continue; } - - for (int i = 0; i < unfilteredMessage.Contents!.Count; i++) + else if (content is FunctionResultContent frc) { - AIContent content = unfilteredMessage.Contents[i]; - if (content is not FunctionResultContent frc - || (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState) - && candidateState.IsHandoffFunction is false)) + // We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary. + // If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can + // come in with the same CallId, and should be considered a new call that may need to be filtered. + if (filteredCallsWithoutResponses.Remove(frc.CallId)) { - // Either this is not a function result content, so we should let it through, or it is a FRC that - // we know is not related to a handoff call. In either case, we should include it. - filteredMessage.Contents.Add(content); + continue; } - else if (candidateState is null) - { - // We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later - filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId) - { - FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count), - }; - } - // else we have seen the corresponding function call and it is a handoff, so we should filter it out. } + + // FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out + retainedContents.Add(content); } - if (filteredMessage.Contents.Count > 0) + if (retainedContents.Count == 0) { - filteredMessages.Add(filteredMessage); + // message was fully filtered, skip it + continue; } + + ChatMessage filteredMessage = unfilteredMessage.Clone(); + filteredMessage.Contents = retainedContents; + retainedMessages.Add(filteredMessage); } - return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index)); - } - - private class FilterCandidateState(string callId) - { - public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; } - - public string CallId => callId; - - public bool? IsHandoffFunction { get; set; } + return retainedMessages; } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffMessageFilterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffMessageFilterTests.cs new file mode 100644 index 0000000000..bbb53c31fb --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffMessageFilterTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffMessageFilterTests +{ + private List CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None) + { + FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId); + FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1); + + FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId); + FunctionResultContent toolResponse = CreateToolResponse(toolCall); + + // Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we + // care, because we do not filter approval content) + ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall); + ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall); + + FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId); + FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2); + + List result = [new(ChatRole.User, "Hello")]; + + // Agent 1 turn + result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?")); + result.Add(new(ChatRole.User, "Please explain temperature")); + + // Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally + if (filter == HandoffToolCallFilteringBehavior.None) + { + result.Add(new(ChatRole.Assistant, [handoffRequest1])); + result.Add(new(ChatRole.Tool, [handoffResponse1])); + } + + // Agent 2 turn + + // Tool approvals are never filtered, so we add them unconditionally + result.Add(new(ChatRole.Assistant, [toolApproval])); + result.Add(new(ChatRole.User, [toolApprovalResponse])); + + // Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally + if (filter != HandoffToolCallFilteringBehavior.All) + { + result.Add(new(ChatRole.Assistant, [toolCall])); + result.Add(new(ChatRole.Tool, [toolResponse])); + } + + result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance.")); + + if (filter == HandoffToolCallFilteringBehavior.None) + { + result.Add(new(ChatRole.Assistant, [handoffRequest2])); + result.Add(new(ChatRole.Tool, [handoffResponse2])); + } + + return result; + } + + private static FunctionCallContent CreateHandoffCall(int id, bool useCallId) + { + string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}"; + string callId = useCallId ? Guid.NewGuid().ToString("N") : callName; + + return new FunctionCallContent(callId, callName); + } + + private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call) + => HandoffAgentExecutor.CreateHandoffResult(call.CallId); + + private static FunctionCallContent CreateToolCall(bool useCallId) + { + const string CallName = "ToolFunction"; + string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName; + + return new FunctionCallContent(callId, CallName); + } + + private static FunctionResultContent CreateToolResponse(FunctionCallContent call) + => new(call.CallId, new object()); + + [Theory] + [InlineData(true, true, HandoffToolCallFilteringBehavior.None)] + [InlineData(true, false, HandoffToolCallFilteringBehavior.None)] + [InlineData(false, true, HandoffToolCallFilteringBehavior.None)] + [InlineData(false, false, HandoffToolCallFilteringBehavior.None)] + [InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)] + [InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)] + [InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)] + [InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)] + [InlineData(true, true, HandoffToolCallFilteringBehavior.All)] + [InlineData(true, false, HandoffToolCallFilteringBehavior.All)] + [InlineData(false, true, HandoffToolCallFilteringBehavior.All)] + [InlineData(false, false, HandoffToolCallFilteringBehavior.All)] + public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior) + { + // Arrange + List messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId); + List expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior); + + HandoffMessagesFilter filter = new(behavior); + + // Act + IEnumerable filteredMessages = filter.FilterMessages(messages); + + // Assert + filteredMessages.Should().BeEquivalentTo(expected); + } +} From adcd2d33f5e32be85ea141fc8cc6fbe590aa0981 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:04:49 -0700 Subject: [PATCH 15/52] .NET: Declarative workflows - Gracefully handle agent scenarios when no response is returned (#5376) * Gracefully handle agent scenarios when no response is returned * Make relevant object disposable and improve exception handling. --- .../AzureAgentProvider.cs | 19 +++++++++++--- .../ObjectModel/InvokeAzureAgentExecutor.cs | 26 ++++++++++++------- .../ObjectModel/QuestionExecutor.cs | 11 +++++--- .../RequestExternalInputExecutor.cs | 6 ++++- .../RequestExternalInputExecutorTest.cs | 23 ++++++++++++++++ 5 files changed, 68 insertions(+), 17 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs index 98e8b7f53f..0a673e2aa5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs @@ -4,7 +4,6 @@ using System; using System.ClientModel.Primitives; using System.Collections.Generic; using System.Collections.ObjectModel; -using System.Linq; using System.Net.Http; using System.Runtime.CompilerServices; using System.Text.Json.Nodes; @@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj include: null, cancellationToken).ConfigureAwait(false); - return newItems.AsChatMessages().Single(); + ChatMessage[] createdMessages = [.. newItems.AsChatMessages()]; + if (createdMessages.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}."); + } + + return createdMessages[0]; IEnumerable GetResponseItems() { @@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj { AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false); ResponseItem[] items = [responseItem.AsResponseResultItem()]; - return items.AsChatMessages().Single(); + ChatMessage[] messages = [.. items.AsChatMessages()]; + if (messages.Length != 1) + { + throw new InvalidOperationException( + $"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}."); + } + + return messages[0]; } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index 86efa6ec43..322c460ee3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -49,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken) { - await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false); + ChatMessage? lastMessage = response.Messages.LastOrDefault(); + if (lastMessage is not null) + { + await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false); + } await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false); } @@ -85,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false); // Attempt to parse the last message as JSON and assign to the response object variable. - try + string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text; + if (!string.IsNullOrEmpty(lastMessageText)) { - JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text); - Dictionary objectProperties = jsonDocument.ParseRecord(VariableType.RecordType); - await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false); - } - catch - { - // Not valid json, skip assignment. + try + { + using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText); + Dictionary objectProperties = jsonDocument.ParseRecord(VariableType.RecordType); + await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false); + } + catch (JsonException) + { + // Not valid json, skip assignment. + } } if (this.Model.Input?.ExternalLoop?.When is not null) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs index 31cb82353e..32ce93c2af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/QuestionExecutor.cs @@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age string? workflowConversationId = context.GetWorkflowConversation(); if (workflowConversationId is not null) { - // Input message always defined if values has been extracted. - ChatMessage input = response.Messages.Last(); - await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false); - await context.SetLastMessageAsync(input).ConfigureAwait(false); + // Input message expected to be defined when values have been extracted, but guard defensively. + ChatMessage? input = response.Messages.LastOrDefault(); + if (input is not null) + { + await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false); + await context.SetLastMessageAsync(input).ConfigureAwait(false); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs index 239b178415..172348b37e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/RequestExternalInputExecutor.cs @@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false); } } - await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false); + ChatMessage? lastMessage = response.Messages.LastOrDefault(); + if (lastMessage is not null) + { + await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false); + } await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false); await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RequestExternalInputExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RequestExternalInputExecutorTest.cs index 8eda895b15..bc1bc10284 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RequestExternalInputExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/RequestExternalInputExecutorTest.cs @@ -85,6 +85,29 @@ public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) : expectMessagesCreated: true); } + [Fact] + public async Task CaptureResponseWithEmptyMessagesAsync() + { + await this.CaptureResponseTestAsync( + displayName: nameof(CaptureResponseWithEmptyMessagesAsync), + variableName: "TestVariable", + messageCount: 0); + } + + [Fact] + public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync() + { + // Arrange + this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System); + + // Act & Assert + await this.CaptureResponseTestAsync( + displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync), + variableName: "TestVariable", + messageCount: 0, + expectMessagesCreated: false); + } + private async Task ExecuteTestAsync( string displayName, string variableName) From 267351b7607595cfcb2d64c739587bc50a476e2f Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 21 Apr 2026 14:27:50 -0400 Subject: [PATCH 16/52] .NET: Expand Workflow Unit Test Coverage (#5390) * refactor: remove dead code * refactor: remove ignore YieldsMessageAttribute - the correct one to use is YieldsOutputAttribute - fixes a comment that mistakenly refers to `.YieldsMessage()` which does not exist. * fix: ChatForwardingExecutor does not use correct role for string messages - make ChatForwardingExecutor use its configured role for string messages rather than always use ChatRole.User - add ChatForwardingExecutor tests * fixup: remove unused attribute * test: Add tests for failure when .AsAgent used on a non-ChatProtocol workflow * test: Add FunctionExecutor tests - also fixes Send and YieldOutput type registration for synchronous output-returning delegates * test: Suppress CodeCoverage for obsolete names * fix: Re-add Obsolete attributes - avoid hard-breaking change - properly notify users that these attributes get ignored --- .../Analysis/SemanticAnalyzer.cs | 2 +- .../AIAgentsAbstractionsExtensions.cs | 11 - .../ChatForwardingExecutor.cs | 2 +- .../FunctionExecutor.cs | 30 +- .../HandoffWorkflowBuilder.cs | 1 + ...sageAttribute.cs => ObsoleteAttributes.cs} | 23 + .../StreamsMessageAttribute.cs | 27 -- .../ChatForwardingExecutorTests.cs | 187 ++++++++ .../FunctionExecutorTests.cs | 422 ++++++++++++++++++ .../WorkflowHostSmokeTests.cs | 27 ++ 10 files changed, 689 insertions(+), 43 deletions(-) rename dotnet/src/Microsoft.Agents.AI.Workflows/{Attributes/YieldsMessageAttribute.cs => ObsoleteAttributes.cs} (62%) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs index 66b67bffdf..7fcbdb18ca 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Generators/Analysis/SemanticAnalyzer.cs @@ -254,7 +254,7 @@ internal static class SemanticAnalyzer /// /// Combines ClassProtocolInfo results into an AnalysisResult for classes that only have IO attributes - /// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsMessage calls in the protocol + /// (no [MessageHandler] methods). This generates only .SendsMessage/.YieldsOutput calls in the protocol /// configuration. /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs index 8c94f4aa85..ef8f800979 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentsAbstractionsExtensions.cs @@ -9,17 +9,6 @@ namespace Microsoft.Agents.AI.Workflows; internal static class AIAgentsAbstractionsExtensions { - public static ChatMessage ToChatMessage(this AgentResponseUpdate update) => - new() - { - AuthorName = update.AuthorName, - Contents = update.Contents, - Role = update.Role ?? ChatRole.User, - CreatedAt = update.CreatedAt, - MessageId = update.MessageId, - RawRepresentation = update.RawRepresentation ?? update, - }; - public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName) => message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs index 93925dec32..bfea6cff97 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ChatForwardingExecutor.cs @@ -47,7 +47,7 @@ public sealed class ChatForwardingExecutor(string id, ChatForwardingExecutorOpti if (this._stringMessageChatRole.HasValue) { routeBuilder = routeBuilder.AddHandler( - (message, context) => context.SendMessageAsync(new ChatMessage(ChatRole.User, message))); + (message, context) => context.SendMessageAsync(new ChatMessage(this._stringMessageChatRole.Value, message))); } routeBuilder.AddHandler(ForwardMessageAsync) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs index d9fed2878f..26a6edfc66 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/FunctionExecutor.cs @@ -73,7 +73,14 @@ public class FunctionExecutor(string id, ExecutorOptions? options = null, IEnumerable? sentMessageTypes = null, IEnumerable? outputTypes = null, - bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync, out var attributeSentTypes, out var attributeYieldTypes), options, attributeSentTypes.Concat(sentMessageTypes ?? []), attributeYieldTypes.Concat(outputTypes ?? []), declareCrossRunShareable) + bool declareCrossRunShareable = false) : this(id, + WrapAction(handlerSync, + out var attributeSentTypes, + out var attributeYieldTypes), + options, + attributeSentTypes.Concat(sentMessageTypes ?? []), + attributeYieldTypes.Concat(outputTypes ?? []), + declareCrossRunShareable) { } } @@ -96,8 +103,18 @@ public class FunctionExecutor(string id, IEnumerable? outputTypes = null, bool declareCrossRunShareable = false) : Executor(id, options, declareCrossRunShareable) { - internal static Func> WrapFunc(Func handlerSync) + internal static Func> WrapFunc(Func handlerSync, out IEnumerable sentTypes, out IEnumerable yieldedTypes) { + if (handlerSync.Method != null) + { + MethodInfo method = handlerSync.Method; + (sentTypes, yieldedTypes) = method.GetAttributeTypes(); + } + else + { + sentTypes = yieldedTypes = []; + } + return RunFuncAsync; ValueTask RunFuncAsync(TInput input, IWorkflowContext workflowContext, CancellationToken cancellationToken) @@ -133,7 +150,14 @@ public class FunctionExecutor(string id, ExecutorOptions? options = null, IEnumerable? sentMessageTypes = null, IEnumerable? outputTypes = null, - bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, sentMessageTypes, outputTypes, declareCrossRunShareable) + bool declareCrossRunShareable = false) : this(id, + WrapFunc(handlerSync, + out var attributeSentTypes, + out var attributeYieldTypes), + options, + attributeSentTypes.Concat(sentMessageTypes ?? []), + attributeYieldTypes.Concat(outputTypes ?? []), + declareCrossRunShareable) { } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs index ba21f9322b..00e030448f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffWorkflowBuilder.cs @@ -20,6 +20,7 @@ internal static class DiagnosticConstants } /// +[ExcludeFromCodeCoverage] // This is obsolete, and 1:1 equivalent to HandoffWorkflowBuilder (no "s") [Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")] #pragma warning disable MAAIW001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs similarity index 62% rename from dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs rename to dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs index 82ca9106b7..87ffb7f89a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Attributes/YieldsMessageAttribute.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/ObsoleteAttributes.cs @@ -29,6 +29,7 @@ namespace Microsoft.Agents.AI.Workflows; /// } /// /// +[Obsolete("Use YieldsOutput instead. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")] [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = true)] public sealed class YieldsMessageAttribute : Attribute { @@ -47,3 +48,25 @@ public sealed class YieldsMessageAttribute : Attribute this.Type = Throw.IfNull(type); } } + +/// +/// This attribute indicates that a message handler streams messages during its execution. +/// +[Obsolete("This attribute does not do anything. The Code Generator and the runtime attribute-based type mapping ignore this attribute.")] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] +public sealed class StreamsMessageAttribute : Attribute +{ + /// + /// The type of the message that the handler yields. + /// + public Type Type { get; } + + /// + /// Indicates that the message handler yields streaming messages during the course of execution. + /// + public StreamsMessageAttribute(Type type) + { + // This attribute is used to mark executors that yield messages. + this.Type = Throw.IfNull(type); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs deleted file mode 100644 index 43f9d59a5f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/StreamsMessageAttribute.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Workflows; - -/// -/// This attribute indicates that a message handler streams messages during its execution. -/// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public sealed class StreamsMessageAttribute : Attribute -{ - /// - /// The type of the message that the handler yields. - /// - public Type Type { get; } - - /// - /// Indicates that the message handler yields streaming messages during the course of execution. - /// - public StreamsMessageAttribute(Type type) - { - // This attribute is used to mark executors that yield messages. - this.Type = Throw.IfNull(type); - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs new file mode 100644 index 0000000000..805128509c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/ChatForwardingExecutorTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +internal enum ChatRoleType +{ + None, + User, + Assistant, + Custom +} + +internal static class ChatRoleTestingExtensions +{ + public const string CustomChatRoleName = nameof(CustomChatRole); + + public static ChatRole CustomChatRole { get; } = new(CustomChatRoleName); + + public static ChatRole? ToChatRole(this ChatRoleType type) + => type switch + { + ChatRoleType.None => null, + ChatRoleType.User => ChatRole.User, + ChatRoleType.Assistant => ChatRole.Assistant, + ChatRoleType.Custom => CustomChatRole, + _ => throw new ArgumentOutOfRangeException( + nameof(type), + type, + $"Invalid ChatRoleType {type}; expecting one of {string.Join(",", + [null, + ChatRole.User, + ChatRole.Assistant, + CustomChatRole])}") + }; +} + +public class ChatForwardingExecutorTests +{ + private async Task RunForwardMessageTestAsync(ChatForwardingExecutor executor, TMessage message) + where TMessage : notnull + { + // Ensure that we have constructed the Protocol (and registered the handlers) + _ = executor.Protocol; + + TestWorkflowContext testContext = new(executor.Id); + object? callResult = await executor.ExecuteCoreAsync(message, new TypeId(typeof(TMessage)), testContext); + + callResult.Should().BeNull(); // ChatForwardingExecutor's do not have a return type + + return testContext; + } + + private const string TestMessageContent = nameof(TestMessageContent); + + [Fact] + public async Task Test_ChatForwardingExecutor_DoesNotForwardStringByDefaultAsync() + { + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + + // Act + Func> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent); + await action.Should().ThrowAsync(); + } + + [Theory] + [InlineData(ChatRoleType.None)] + [InlineData(ChatRoleType.User)] + [InlineData(ChatRoleType.Assistant)] + [InlineData(ChatRoleType.Custom)] + internal async Task Test_ChatForwardingExecutor_ForwardsStringIfConfiguredAsync(ChatRoleType chatRoleType) + { + // Arrange + ChatForwardingExecutorOptions options = new() + { + StringMessageChatRole = chatRoleType.ToChatRole() + }; + + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor), options); + + // Act + Func> action = () => this.RunForwardMessageTestAsync(executor, TestMessageContent); + + // Assert + if (options.StringMessageChatRole is ChatRole chatRole) + { + TestWorkflowContext testContext = await action(); + + testContext.SentMessages.Should().HaveCount(1) + .And.BeEquivalentTo([new ChatMessage(chatRole, TestMessageContent)]); + } + else + { + await action.Should().ThrowAsync(); + } + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageUnmodifiedAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ChatMessage testMessage = new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent); + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessage); + + // Assert + testContext.SentMessages.Should().ContainSingle(message => ReferenceEquals(message, testMessage)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageListUnmodifiedAsync(bool sendAsIEnumerable) + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + List testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext + = sendAsIEnumerable + ? await this.RunForwardMessageTestAsync>(executor, testMessages) + : await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages)); + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsChatMessageArrayUnchangedAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ChatMessage[] testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => ReferenceEquals(messages, testMessages)); + } + + [Fact] + public async Task Test_ChatForwardingExecutor_ForwardsMessageCollectionAsListAsync() + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + ConcurrentBag testMessages = [new(ChatRoleTestingExtensions.CustomChatRole, TestMessageContent), + new(ChatRole.Assistant, "ResponseMessage")]; + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testMessages); + + // Assert + testContext.SentMessages.Should().ContainSingle(messages => !ReferenceEquals(messages, testMessages)) + .And.Subject.Single().Should().BeEquivalentTo(testMessages); + } + + [Theory] + [InlineData(null)] + [InlineData(false)] + [InlineData(true)] + public async Task Test_ChatForwardingExecutor_ForwardsTurnTokenUnmodifiedAsync(bool? emitEvents) + { + // Arrange + ChatForwardingExecutor executor = new(nameof(ChatForwardingExecutor)); + TurnToken testTurnToken = new(emitEvents); + + // Act + TestWorkflowContext testContext = await this.RunForwardMessageTestAsync(executor, testTurnToken); + + // Assert + testContext.SentMessages.Should().BeEquivalentTo([testTurnToken]); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs new file mode 100644 index 0000000000..164ac58ed8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/FunctionExecutorTests.cs @@ -0,0 +1,422 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class ExecutorTestsBase +{ + public sealed record TextMessage(string Text); + + public const string TestMessageContent = nameof(TestMessage); + public static TextMessage TestMessage { get; } = new(TestMessageContent); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "Test Object")] + public sealed record DataMessage(string Base64Bytes) + { + private static string ToBase64String(string text, Encoding? expectedEncoding) + { + byte[] bytes = (expectedEncoding ?? Encoding.UTF8).GetBytes(text); + return Convert.ToBase64String(bytes); + } + + public DataMessage(TextMessage textMessage, Encoding? expectedEncoding = null) : this(ToBase64String(textMessage.Text, expectedEncoding)) + { } + } + + public const string DataMessageContent = nameof(DataMessage); + public static DataMessage TestDataMessage { get; } = new(TestMessage); + + public sealed class InvocationEvent(TMessage message) : WorkflowEvent(message) + { + public TMessage Message => message; + } + + internal sealed record ExecutorTestResult(TestWorkflowContext Context, object? CallResult); + + internal async ValueTask Run_FunctionExecutor_MessageHandlerTestAsync(Executor executor, TMessage message, CancellationToken cancellationToken = default) + where TMessage : notnull + { + TestWorkflowContext workflowContext = this.CreateWorkflowContext(executor); + _ = executor.DescribeProtocol(); + + object? result = await executor.ExecuteCoreAsync(message, new(typeof(TMessage)), workflowContext, cancellationToken); + + return new(workflowContext, result); + } + + internal static void CheckInvoked(ExecutorTestResult result, TMessage expectedInput, object? expectedCallResult = null) + where TMessage : class + { + result.CallResult.Should().Be(expectedCallResult); + + result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent + && ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput) + .And.Contain(evt => evt is ExecutorCompletedEvent + && ((ExecutorCompletedEvent)evt).Data == expectedCallResult); + } + + internal static void CheckInvoked(ExecutorTestResult result, TMessage expectedInput, TOutput expectedCallResult) + where TMessage : class + where TOutput : class + { + result.CallResult.Should().Be(expectedCallResult); + + result.Context.EmittedEvents.Should().Contain(evt => evt is ExecutorInvokedEvent + && ((ExecutorInvokedEvent)evt).Data as TMessage == expectedInput) + .And.Contain(evt => evt is ExecutorCompletedEvent + && ((ExecutorCompletedEvent)evt).Data as TOutput == expectedCallResult); + } + + internal TestWorkflowContext CreateWorkflowContext(Executor executor) => new(executor.Id); +} + +public class FunctionExecutorTests : ExecutorTestsBase +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_FunctionExecutor__1_InvokesDelegateSuccessfullyAsync(bool useAsync) + { + // Arrange + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync) + : new(nameof(FunctionExecutor<>), MessageHandler); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + + // Assert + CheckInvoked(result, TestMessage); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => default; + + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) { } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_FunctionExecutor__2_InvokesDelegateSuccessfullyAsync(bool useAsync) + { + // Arrange + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<,>), MessageHandlerAsync) + : new(nameof(FunctionExecutor<,>), MessageHandler); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + + // Assert + CheckInvoked(result, TestMessage, TestDataMessage); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(new DataMessage(message)); + + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(message); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__1_SendTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + IEnumerable? sendTypes = useAnnotated + ? null + : [typeof(TextMessage)]; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, sentMessageTypes: sendTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, sentMessageTypes: sendTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]); + protocol.Yields.Should().BeEmpty(); + + // Helpers + [SendsMessage(typeof(TextMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + + [SendsMessage(typeof(TextMessage))] + void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__2_SendTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = false, + AutoYieldOutputHandlerResultObject = false + }; + + IEnumerable? sendTypes = useAnnotated + ? null + : [typeof(TextMessage)]; + + FunctionExecutor executor + = useAsync + ? new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, options, sentMessageTypes: sendTypes) + : new(nameof(FunctionExecutor<,>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, options, sentMessageTypes: sendTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Sends.Should().BeEquivalentTo([typeof(TextMessage)]); + protocol.Yields.Should().BeEmpty(); + + // Helpers + [SendsMessage(typeof(TextMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + async ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + await context.SendMessageAsync(message, cancellationToken); + return new(message); + } + + [SendsMessage(typeof(TextMessage))] + DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); + return new(message); + } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__1_YieldTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + IEnumerable? yieldTypes = useAnnotated + ? null + : [typeof(DataMessage)]; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, outputTypes: yieldTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, outputTypes: yieldTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + protocol.Sends.Should().BeEmpty(); + + // Helpers + [YieldsOutput(typeof(DataMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.YieldOutputAsync(new DataMessage(message), cancellationToken); + + [YieldsOutput(typeof(DataMessage))] + void MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Test_FunctionExecutor__2_YieldTypesAreRegistered(bool useAsync, bool useAnnotated) + { + // Arrange + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = false, + AutoYieldOutputHandlerResultObject = false + }; + + IEnumerable? yieldTypes = useAnnotated + ? null + : [typeof(DataMessage)]; + + FunctionExecutor executor + = useAsync + ? new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotatedAsync + : MessageHandlerAsync, options, outputTypes: yieldTypes) + : new(nameof(FunctionExecutor<>), useAnnotated ? MessageHandlerAnnotated + : MessageHandler, options, outputTypes: yieldTypes); + + // Act + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + protocol.Sends.Should().BeEmpty(); + + // Helpers + [YieldsOutput(typeof(DataMessage))] + ValueTask MessageHandlerAnnotatedAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandlerAsync(message, context, cancellationToken); + + async ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + await context.YieldOutputAsync(new DataMessage(message), cancellationToken); + return new(message); + } + + [YieldsOutput(typeof(DataMessage))] + DataMessage MessageHandlerAnnotated(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => MessageHandler(message, context, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + { + context.YieldOutputAsync(new DataMessage(message), cancellationToken).AsTask().GetAwaiter().GetResult(); + return new(message); + } +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public void Test_FunctionExecutor__1_ExecutorOptionsAreNoOp(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue) + { + // Because FunctionExecutor does not have a rail for a returned value, setting up options for it will + // not register any output types + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = autoSendReturnValue, + AutoYieldOutputHandlerResultObject = autoYieldReturnValue + }; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options) + : new(nameof(FunctionExecutor<>), MessageHandler, options); + + ProtocolDescriptor protocol = executor.DescribeProtocol(); + protocol.Sends.Should().BeEmpty(); + protocol.Yields.Should().BeEmpty(); + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken); + +#pragma warning disable VSTHRD002 // Avoid problematic synchronous waits + void MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => context.SendMessageAsync(message, cancellationToken).AsTask().GetAwaiter().GetResult(); +#pragma warning restore VSTHRD002 // Avoid problematic synchronous waits + } + + [Theory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, true, true)] + [InlineData(true, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public async Task Test_FunctionExecutor__2_ExecutorOptionsCauseCorrectRegistration_AndAutoBehaviorAsync(bool useAsync, bool autoSendReturnValue, bool autoYieldReturnValue) + { + // Arrange + // Because FunctionExecutor does not have a rail for a returned value, setting up options for it will + // not register any output types + ExecutorOptions options = new() + { + AutoSendMessageHandlerResultObject = autoSendReturnValue, + AutoYieldOutputHandlerResultObject = autoYieldReturnValue + }; + + FunctionExecutor executor = useAsync + ? new(nameof(FunctionExecutor<>), MessageHandlerAsync, options) + : new(nameof(FunctionExecutor<>), MessageHandler, options); + + // Act + ExecutorTestResult result = await this.Run_FunctionExecutor_MessageHandlerTestAsync(executor, TestMessage); + ProtocolDescriptor protocol = executor.DescribeProtocol(); + + // Assert + CheckInvoked(result, TestMessage, TestDataMessage); + if (autoSendReturnValue) + { + protocol.Sends.Should().BeEquivalentTo([typeof(DataMessage)]); + result.Context.SentMessages.Should().ContainEquivalentOf(TestDataMessage); + } + else + { + protocol.Sends.Should().BeEmpty(); + result.Context.SentMessages.Should().NotContainEquivalentOf(TestDataMessage); + } + + if (autoYieldReturnValue) + { + protocol.Yields.Should().BeEquivalentTo([typeof(DataMessage)]); + result.Context.YieldedOutputs.Should().ContainEquivalentOf(TestDataMessage); + } + else + { + protocol.Yields.Should().BeEmpty(); + result.Context.YieldedOutputs.Should().NotContainEquivalentOf(TestDataMessage); + } + + // Helpers + ValueTask MessageHandlerAsync(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(new DataMessage(message)); + + DataMessage MessageHandler(TextMessage message, IWorkflowContext context, CancellationToken cancellationToken) + => new(message); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs index 3fa17a34bb..7b9b428871 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs @@ -206,6 +206,14 @@ internal sealed class TurnTrackingStartExecutor : ChatProtocolExecutor } } +public class NonChatProtocolExecutor() : Executor(nameof(NonChatProtocolExecutor)) +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) + { + return default; + } +} + public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase { private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent @@ -732,6 +740,25 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase .BeEmpty(); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Test_AsAgent_FailsWhenNotChatProtocolAsync(bool runAsync) + { + // Arrange + NonChatProtocolExecutor executor = new(); + executor.DescribeProtocol().IsChatProtocol().Should().BeFalse(); + + Workflow workflow = new WorkflowBuilder(executor).Build(); + AIAgent workflowAsAgent = workflow.AsAIAgent(); + + Func action = runAsync + ? () => workflowAsAgent.RunStreamingAsync().ToAgentResponseAsync() + : () => workflowAsAgent.RunAsync(); + + await action.Should().ThrowAsync(); + } + private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync) { // Arrange From 8f17067383154e87e3a3c8ae673c7b5f1cf71add Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Tue, 21 Apr 2026 15:07:44 -0400 Subject: [PATCH 17/52] .NET: Update .NET package version 1.2.0 (#5364) * release: Update version for .NET (1.2.0) * release: Update preview date tag --- dotnet/nuget/nuget-package.props | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 9d91eebf28..9baf1e5885 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,13 +1,14 @@ - 1.1.0 + 1.2.0 1 + 260421 $(VersionPrefix)-rc$(RCNumber) - $(VersionPrefix)-$(VersionSuffix).260410.1 - $(VersionPrefix)-preview.260410.1 + $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 + $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.1.0 + 1.2.0 Debug;Release;Publish true From aa582d021d69dd3b047d7664ced090dcc08b56f2 Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Tue, 21 Apr 2026 12:40:53 -0700 Subject: [PATCH 18/52] Python: feat(evals): add ground_truth support for similarity evaluator (#5234) * feat(evals): add ground_truth support for similarity evaluator - Include expected_output as ground_truth in Foundry JSONL dataset rows - Add ground_truth to item schema and data mapping for similarity evaluator - Add expected_output parameter to evaluate_workflow - Add similarity Pattern 3 to evaluate_agent and evaluate_workflow samples - Add tests for ground_truth in dataset, schema, and evaluate_workflow * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: wrap long line to satisfy ruff E501 --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../core/agent_framework/_evaluation.py | 20 ++- .../agent_framework_foundry/_foundry_evals.py | 20 ++- .../foundry/tests/test_foundry_evals.py | 148 ++++++++++++++++++ .../foundry_evals/evaluate_agent_sample.py | 38 ++++- .../foundry_evals/evaluate_workflow_sample.py | 51 +++++- 5 files changed, 270 insertions(+), 7 deletions(-) diff --git a/python/packages/core/agent_framework/_evaluation.py b/python/packages/core/agent_framework/_evaluation.py index 682903d448..64fab0eacb 100644 --- a/python/packages/core/agent_framework/_evaluation.py +++ b/python/packages/core/agent_framework/_evaluation.py @@ -1659,6 +1659,7 @@ async def evaluate_workflow( workflow: Workflow, workflow_result: WorkflowRunResult | None = None, queries: str | Sequence[str] | None = None, + expected_output: str | Sequence[str] | None = None, evaluators: Evaluator | Callable[..., Any] | Sequence[Evaluator | Callable[..., Any]], eval_name: str | None = None, include_overall: bool = True, @@ -1683,6 +1684,11 @@ async def evaluate_workflow( workflow: The workflow instance. workflow_result: A completed ``WorkflowRunResult``. queries: Test queries to run through the workflow. + expected_output: Ground-truth expected output(s), one per query. A + single string is wrapped into a one-element list. When provided, + must be the same length as ``queries``. Each value is stamped on + the corresponding ``EvalItem.expected_output`` for evaluators + that compare against a reference answer (e.g. similarity). evaluators: One or more ``Evaluator`` instances. eval_name: Display name for the evaluation. include_overall: Whether to evaluate the workflow's final output. @@ -1720,10 +1726,20 @@ async def evaluate_workflow( # Normalize singular query to list if isinstance(queries, str): queries = [queries] + if isinstance(expected_output, str): + expected_output = [expected_output] if workflow_result is None and queries is None: raise ValueError("Provide either 'workflow_result' or 'queries'.") + if expected_output is not None and queries is None: + raise ValueError( + "Provide 'queries' when using 'expected_output';" + " 'expected_output' is not supported with 'workflow_result' only." + ) + if expected_output is not None and queries is not None and len(expected_output) != len(queries): + raise ValueError(f"Got {len(queries)} queries but {len(expected_output)} expected_output values.") + if num_repetitions < 1: raise ValueError(f"num_repetitions must be >= 1, got {num_repetitions}.") @@ -1737,7 +1753,7 @@ async def evaluate_workflow( if queries is not None: results_list: list[WRR] = [] for _rep in range(num_repetitions): - for q in queries: + for qi, q in enumerate(queries): result = await workflow.run(q) if not isinstance(result, WRR): raise TypeError(f"Expected WorkflowRunResult from workflow.run(), got {type(result).__name__}.") @@ -1746,6 +1762,8 @@ async def evaluate_workflow( if include_overall: overall_item = _build_overall_item(q, result) if overall_item: + if expected_output is not None: + overall_item.expected_output = expected_output[qi] overall_items.append(overall_item) else: assert workflow_result is not None # noqa: S101 # nosec B101 diff --git a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py index a5033a8e87..2f68816591 100644 --- a/python/packages/foundry/agent_framework_foundry/_foundry_evals.py +++ b/python/packages/foundry/agent_framework_foundry/_foundry_evals.py @@ -75,6 +75,11 @@ _TOOL_EVALUATORS: set[str] = { "builtin.tool_call_success", } +# Evaluators that require a ground_truth / expected_output field. +_GROUND_TRUTH_EVALUATORS: set[str] = { + "builtin.similarity", +} + _BUILTIN_EVALUATORS: dict[str, str] = { # Agent behavior "intent_resolution": "builtin.intent_resolution", @@ -196,6 +201,8 @@ def _build_testing_criteria( } if qualified == "builtin.groundedness": mapping["context"] = "{{item.context}}" + if qualified in _GROUND_TRUTH_EVALUATORS: + mapping["ground_truth"] = "{{item.ground_truth}}" if qualified in _TOOL_EVALUATORS: mapping["tool_definitions"] = "{{item.tool_definitions}}" entry["data_mapping"] = mapping @@ -204,7 +211,9 @@ def _build_testing_criteria( return criteria -def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) -> dict[str, Any]: +def _build_item_schema( + *, has_context: bool = False, has_tools: bool = False, has_ground_truth: bool = False +) -> dict[str, Any]: """Build the ``item_schema`` for custom JSONL eval definitions.""" properties: dict[str, Any] = { "query": {"type": "string"}, @@ -214,6 +223,8 @@ def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) -> } if has_context: properties["context"] = {"type": "string"} + if has_ground_truth: + properties["ground_truth"] = {"type": "string"} if has_tools: properties["tool_definitions"] = {"type": "array"} return { @@ -681,16 +692,21 @@ class FoundryEvals: ] if item.context: d["context"] = item.context + if item.expected_output is not None: + d["ground_truth"] = item.expected_output dicts.append(d) has_context = any("context" in d for d in dicts) + has_ground_truth = any("ground_truth" in d for d in dicts) has_tools = any("tool_definitions" in d for d in dicts) eval_obj = await self._client.evals.create( name=eval_name, data_source_config={ # type: ignore[arg-type] # pyright: ignore[reportArgumentType] "type": "custom", - "item_schema": _build_item_schema(has_context=has_context, has_tools=has_tools), + "item_schema": _build_item_schema( + has_context=has_context, has_ground_truth=has_ground_truth, has_tools=has_tools + ), "include_sample_schema": True, }, testing_criteria=_build_testing_criteria( # type: ignore[arg-type] # pyright: ignore[reportArgumentType] diff --git a/python/packages/foundry/tests/test_foundry_evals.py b/python/packages/foundry/tests/test_foundry_evals.py index cef890c7af..d11999b76a 100644 --- a/python/packages/foundry/tests/test_foundry_evals.py +++ b/python/packages/foundry/tests/test_foundry_evals.py @@ -769,6 +769,10 @@ class TestBuildTestingCriteria: assert c["data_mapping"]["query"] == "{{item.query}}", f"{c['name']}" assert c["data_mapping"]["response"] == "{{item.response}}", f"{c['name']}" + def test_similarity_includes_ground_truth(self) -> None: + criteria = _build_testing_criteria(["similarity"], "gpt-4o", include_data_mapping=True) + assert criteria[0]["data_mapping"]["ground_truth"] == "{{item.ground_truth}}" + def test_all_tool_evaluators_include_tool_definitions(self) -> None: tool_evals = [ "tool_call_accuracy", @@ -801,6 +805,10 @@ class TestBuildItemSchema: schema = _build_item_schema(has_tools=True) assert "tool_definitions" in schema["properties"] + def test_with_ground_truth(self) -> None: + schema = _build_item_schema(has_ground_truth=True) + assert "ground_truth" in schema["properties"] + def test_with_context_and_tools(self) -> None: schema = _build_item_schema(has_context=True, has_tools=True) assert "context" in schema["properties"] @@ -1015,6 +1023,50 @@ class TestFoundryEvals: assert ds["type"] == "jsonl" assert "tool_definitions" in ds["source"]["content"][0]["item"] + async def test_evaluate_ground_truth_in_dataset(self) -> None: + """Items with expected_output include ground_truth in the JSONL payload.""" + mock_client = MagicMock() + + mock_eval = MagicMock() + mock_eval.id = "eval_gt" + mock_client.evals.create = AsyncMock(return_value=mock_eval) + + mock_run = MagicMock() + mock_run.id = "run_gt" + mock_client.evals.runs.create = AsyncMock(return_value=mock_run) + + mock_completed = MagicMock() + mock_completed.status = "completed" + mock_completed.result_counts = _rc(passed=1) + mock_completed.report_url = None + mock_completed.per_testing_criteria_results = None + mock_client.evals.runs.retrieve = AsyncMock(return_value=mock_completed) + + items = [ + EvalItem( + conversation=[Message("user", ["What is 2+2?"]), Message("assistant", ["4"])], + expected_output="4", + ), + ] + + fe = FoundryEvals( + client=mock_client, + model="gpt-4o", + evaluators=[FoundryEvals.SIMILARITY], + ) + await fe.evaluate(items) + + # Verify ground_truth appears in JSONL data + run_call = mock_client.evals.runs.create.call_args + ds = run_call.kwargs["data_source"] + assert ds["type"] == "jsonl" + assert ds["source"]["content"][0]["item"]["ground_truth"] == "4" + + # Verify item_schema includes ground_truth + create_call = mock_client.evals.create.call_args + schema = create_call.kwargs["data_source_config"]["item_schema"] + assert "ground_truth" in schema["properties"] + async def test_evaluate_image_content_in_dataset(self) -> None: """Image content in conversations is preserved in the JSONL payload.""" mock_client = MagicMock() @@ -1988,6 +2040,102 @@ class TestEvaluateWorkflow: "researcher has tools — should get tool_call_accuracy" ) + async def test_expected_output_stamps_overall_items(self) -> None: + """expected_output is stamped on overall items as ground_truth in the dataset.""" + mock_oai = self._mock_oai_client() + + aer = _make_agent_exec_response("agent", "Response", ["Query"]) + final_output = [Message("assistant", ["Final answer"])] + + events = [ + WorkflowEvent.executor_invoked("agent", "Test query"), + WorkflowEvent.executor_completed("agent", [aer]), + WorkflowEvent.output("end", final_output), + ] + wf_result = WorkflowRunResult(events, []) + + mock_workflow = MagicMock() + mock_workflow.executors = {} + mock_workflow.run = AsyncMock(return_value=wf_result) + + results = await evaluate_workflow( + workflow=mock_workflow, + queries=["Test query"], + expected_output=["Expected answer"], + evaluators=FoundryEvals( + client=mock_oai, + model="gpt-4o", + evaluators=[FoundryEvals.SIMILARITY], + ), + ) + + assert results[0].status == "completed" + + # Verify overall eval's dataset includes ground_truth + # The overall eval is the last evals.runs.create call + calls = mock_oai.evals.runs.create.call_args_list + overall_call = calls[-1] + ds = overall_call.kwargs["data_source"] + overall_item = ds["source"]["content"][0]["item"] + assert overall_item["ground_truth"] == "Expected answer" + + async def test_expected_output_with_num_repetitions(self) -> None: + """expected_output is correctly stamped on overall items across multiple repetitions.""" + mock_oai = self._mock_oai_client() + + aer = _make_agent_exec_response("agent", "Response", ["Query"]) + final_output = [Message("assistant", ["Final answer"])] + + events = [ + WorkflowEvent.executor_invoked("agent", "Test query"), + WorkflowEvent.executor_completed("agent", [aer]), + WorkflowEvent.output("end", final_output), + ] + wf_result = WorkflowRunResult(events, []) + + mock_workflow = MagicMock() + mock_workflow.executors = {} + mock_workflow.run = AsyncMock(return_value=wf_result) + + results = await evaluate_workflow( + workflow=mock_workflow, + queries=["Test query"], + expected_output=["Expected answer"], + evaluators=FoundryEvals( + client=mock_oai, + model="gpt-4o", + evaluators=[FoundryEvals.SIMILARITY], + ), + num_repetitions=2, + ) + + assert results[0].status == "completed" + + # workflow.run should be called twice (once per repetition) + assert mock_workflow.run.call_count == 2 + + # Verify all overall items have ground_truth stamped + calls = mock_oai.evals.runs.create.call_args_list + overall_call = calls[-1] + ds = overall_call.kwargs["data_source"] + items = ds["source"]["content"] + assert len(items) == 2 + for item in items: + assert item["item"]["ground_truth"] == "Expected answer" + + async def test_expected_output_length_mismatch_raises(self) -> None: + """Mismatched queries and expected_output lengths raise ValueError.""" + mock_oai = MagicMock() + mock_workflow = MagicMock() + + with pytest.raises(ValueError, match="expected_output"): + await evaluate_workflow( + workflow=mock_workflow, + queries=["q1", "q2"], + expected_output=["e1"], + evaluators=FoundryEvals(client=mock_oai, model="gpt-4o"), + ) + # --------------------------------------------------------------------------- # EvalItemResult and EvalScoreResult diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py index 7c8b306c11..42b67b91e7 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py @@ -2,9 +2,10 @@ """Evaluate an agent using Azure AI Foundry's built-in evaluators. -This sample demonstrates two patterns: +This sample demonstrates three patterns: 1. evaluate_agent(responses=...) — Evaluate a response you already have. 2. evaluate_agent(queries=...) — Run the agent against test queries and evaluate in one call. +3. Similarity — Compare agent output against ground-truth reference answers. See ``evaluate_tool_calls_sample.py`` for tool-call accuracy evaluation. @@ -149,6 +150,41 @@ async def main() -> None: else: print(f"[FAIL] {r.failed} failed") + # ========================================================================= + # Pattern 3: Similarity — compare agent output to ground-truth answers + # ========================================================================= + print() + print("=" * 60) + print("Pattern 3: Similarity evaluation with ground truth") + print("=" * 60) + + # Similarity requires expected_output — a reference answer per query + # that the evaluator compares against the agent's actual response. + results = await evaluate_agent( + agent=agent, + queries=[ + "What's the weather like in Seattle?", + "How much does a flight from Seattle to Paris cost?", + ], + expected_output=[ + "62°F, cloudy with a chance of rain", + "Flights from Seattle to Paris: $450 round-trip", + ], + evaluators=FoundryEvals( + client=chat_client, + evaluators=[FoundryEvals.SIMILARITY], + ), + ) + + for r in results: + print(f"Status: {r.status}") + print(f"Results: {r.passed}/{r.total} passed") + print(f"Portal: {r.report_url}") + if r.all_passed: + print("[PASS] All passed") + else: + print(f"[FAIL] {r.failed} failed") + if __name__ == "__main__": asyncio.run(main()) diff --git a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py index e2861324f6..b9ffa1f6dd 100644 --- a/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py +++ b/python/samples/05-end-to-end/evaluation/foundry_evals/evaluate_workflow_sample.py @@ -2,11 +2,12 @@ """Evaluate a multi-agent workflow using Azure AI Foundry evaluators. -This sample demonstrates two patterns: +This sample demonstrates three patterns: 1. Post-hoc: Run the workflow, then evaluate the result you already have. 2. Run + evaluate: Pass queries and let evaluate_workflow() run the workflow for you. +3. Similarity: Evaluate the workflow's final output against ground-truth reference answers. -Both patterns return a list of results (one per provider), each with a per-agent +Patterns 1 & 2 return a list of results (one per provider), each with a per-agent breakdown in sub_results so you can identify which agent is underperforming. Prerequisites: @@ -79,7 +80,6 @@ async def main() -> None: # 4. Create the evaluator — provider config goes here, once evals = FoundryEvals(client=client) - # ========================================================================= # Pattern 1: Post-hoc — evaluate a workflow run you already did # ========================================================================= @@ -143,6 +143,43 @@ async def main() -> None: if agent_eval.report_url: print(f" Portal: {agent_eval.report_url}") + # ========================================================================= + # Pattern 3: Similarity — compare workflow output to ground-truth answers + # ========================================================================= + # Build a fresh workflow to avoid stale session state from Pattern 2. + workflow3 = SequentialBuilder(participants=[researcher, planner]).build() + + print() + print("=" * 60) + print("Pattern 3: Similarity evaluation with ground truth") + print("=" * 60) + + # Similarity compares the final workflow output against a reference answer, + # so per-agent breakdown is disabled — individual agents don't have their + # own ground-truth targets. + eval_results = await evaluate_workflow( + workflow=workflow3, + queries=[ + "Plan a trip from Seattle to Paris", + "Plan a trip from London to Tokyo", + ], + expected_output=[ + "Pack layers and an umbrella for Paris. Flights from Seattle are around $450 round-trip.", + "Bring warm clothing for Tokyo in spring. Flights from London are around $500 round-trip.", + ], + evaluators=FoundryEvals( + client=client, + evaluators=[FoundryEvals.SIMILARITY], + ), + include_per_agent=False, + ) + + for r in eval_results: + print(f"\nOverall: {r.status}") + print(f" Passed: {r.passed}/{r.total}") + if r.report_url: + print(f" Portal: {r.report_url}") + if __name__ == "__main__": asyncio.run(main()) @@ -173,4 +210,12 @@ Overall: completed Per-agent breakdown: researcher: 2/2 passed planner: 2/2 passed + +============================================================ +Pattern 3: Similarity evaluation with ground truth +============================================================ + +Overall: completed + Passed: 2/2 + Portal: https://ai.azure.com/... """ From 57fa8ea9022ac9ec39fb5ececb020aa042599c8f Mon Sep 17 00:00:00 2001 From: chetantoshniwal Date: Tue, 21 Apr 2026 12:44:25 -0700 Subject: [PATCH 19/52] Python: Fix OpenAIEmbeddingClient to use AsyncOpenAI for /openai/v1 endpoints (#5137) * Fix OpenAIEmbeddingClient with /openai/v1 endpoint (#5068) When base_url ends with /openai/v1/ and a credential is provided, load_openai_service_settings was creating an AsyncAzureOpenAI client. The Azure SDK rewrites deployment-based endpoints (including /embeddings) by inserting /deployments/{model}/ into the URL, producing 404s on the OpenAI-compatible /openai/v1 endpoint. Use AsyncOpenAI instead of AsyncAzureOpenAI when the resolved base_url targets /openai/v1, converting the Azure token provider to an async api_key callable. The responses_mode path is unaffected because the Responses API (/responses) is not in the SDK's rewrite list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix OpenAIEmbeddingClient to use AsyncOpenAI for /openai/v1 endpoints Fixes #5068 * Address review feedback: improve test coverage and remove unrelated changes - Revert unrelated formatting change in test_a2a_agent.py - Fix test_init_with_openai_v1_base_url_and_api_key_uses_openai_client to exercise the Azure settings path (via AZURE_OPENAI_BASE_URL env var) instead of the plain OpenAI path, covering the elif api_key branch - Add _ensure_async_token_provider unit tests for both sync and async token providers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5068: Python: [Bug]: `OpenAIEmbeddingClient` does not work with `/openai/v1` endpoint --------- Co-authored-by: MAF Dashboard Bot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com> --- .../openai/agent_framework_openai/_shared.py | 37 ++++++++++ .../test_openai_embedding_client_azure.py | 74 ++++++++++++++++++- .../openai/tests/openai/test_openai_shared.py | 26 ++++++- 3 files changed, 135 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_shared.py b/python/packages/openai/agent_framework_openai/_shared.py index f1d0728f61..7fb12ad14e 100644 --- a/python/packages/openai/agent_framework_openai/_shared.py +++ b/python/packages/openai/agent_framework_openai/_shared.py @@ -282,9 +282,46 @@ def load_openai_service_settings( "Azure OpenAI client requires either an API key or an Azure AD token provider." " This can be provided either as a callable api_key or via the credential parameter." ) + + # The /openai/v1 endpoint exposes an OpenAI-compatible API surface. + # AsyncAzureOpenAI rewrites certain request paths (e.g. /embeddings, + # /chat/completions) by inserting /deployments/{model}/, which produces + # 404s on this endpoint. Use AsyncOpenAI instead so request URLs are + # sent as-is. responses_mode is excluded because the Responses API path + # (/responses) is not rewritten by the Azure SDK. + resolved_base_url = client_args.get("base_url", "") + if not responses_mode and resolved_base_url and resolved_base_url.rstrip("/").endswith("/openai/v1"): + openai_args: dict[str, Any] = { + "base_url": resolved_base_url, + "default_headers": client_args.get("default_headers"), + } + if "azure_ad_token_provider" in client_args: + openai_args["api_key"] = _ensure_async_token_provider(client_args["azure_ad_token_provider"]) + elif "api_key" in client_args: + openai_args["api_key"] = client_args["api_key"] + return azure_settings, AsyncOpenAI(**openai_args), True # type: ignore[return-value] + return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value] +def _ensure_async_token_provider( + provider: AzureTokenProvider, +) -> Callable[[], Awaitable[str]]: + """Wrap a (possibly synchronous) token provider so it always returns an awaitable. + + ``AsyncOpenAI`` requires callable ``api_key`` values to return ``Awaitable[str]``. + Azure token providers may return a plain ``str``, so this normalises them. + """ + + async def _wrapper() -> str: + result = provider() + if isinstance(result, str): + return result + return await result + + return _wrapper + + def _resolve_azure_credential_to_token_provider( credential: AzureCredentialTypes | AzureTokenProvider, ) -> AzureTokenProvider: diff --git a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py index 2d3c457bf6..4e7a584874 100644 --- a/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_embedding_client_azure.py @@ -11,7 +11,7 @@ import pytest from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials_async import AsyncTokenCredential from azure.identity.aio import AzureCliCredential -from openai import AsyncAzureOpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from agent_framework_openai import OpenAIEmbeddingClient, OpenAIEmbeddingOptions @@ -196,6 +196,78 @@ def test_openai_base_url_wins_over_azure_aliases(monkeypatch, azure_openai_unit_ assert client.azure_endpoint is None +def test_init_with_openai_v1_base_url_and_credential_uses_openai_client(monkeypatch) -> None: + for env in [ + "OPENAI_API_KEY", + "OPENAI_ORG_ID", + "OPENAI_MODEL", + "OPENAI_EMBEDDING_MODEL", + "OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", + "AZURE_OPENAI_API_VERSION", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_CHAT_COMPLETION_MODEL", + ]: + monkeypatch.delenv(env, raising=False) + + client = OpenAIEmbeddingClient( + base_url="https://myproject.openai.azure.com/openai/v1/", + model="text-embedding-3-large", + credential=lambda: "fake-token", + ) + + assert client.model == "text-embedding-3-large" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert isinstance(client.client, AsyncOpenAI) + assert client.OTEL_PROVIDER_NAME == "azure.ai.openai" + assert str(client.client.base_url).rstrip("/").endswith("/openai/v1") + + +def test_init_with_openai_v1_base_url_and_api_key_uses_openai_client(monkeypatch) -> None: + for env in [ + "OPENAI_API_KEY", + "OPENAI_ORG_ID", + "OPENAI_MODEL", + "OPENAI_EMBEDDING_MODEL", + "OPENAI_BASE_URL", + "AZURE_OPENAI_ENDPOINT", + "AZURE_OPENAI_BASE_URL", + "AZURE_OPENAI_API_KEY", + "AZURE_OPENAI_EMBEDDING_MODEL", + "AZURE_OPENAI_MODEL", + "AZURE_OPENAI_API_VERSION", + "AZURE_OPENAI_CHAT_MODEL", + "AZURE_OPENAI_CHAT_COMPLETION_MODEL", + ]: + monkeypatch.delenv(env, raising=False) + + # AZURE_OPENAI_BASE_URL + AZURE_OPENAI_API_KEY enter the Azure settings + # path without an explicit endpoint parameter; the /openai/v1 suffix + # should still produce AsyncOpenAI (not AsyncAzureOpenAI). + monkeypatch.setenv("AZURE_OPENAI_BASE_URL", "https://myproject.openai.azure.com/openai/v1/") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "test-api-key") + + client = OpenAIEmbeddingClient(model="text-embedding-3-large") + + assert client.model == "text-embedding-3-large" + assert not isinstance(client.client, AsyncAzureOpenAI) + assert isinstance(client.client, AsyncOpenAI) + assert str(client.client.base_url).rstrip("/").endswith("/openai/v1") + + +def test_init_with_azure_endpoint_still_uses_azure_client(azure_openai_unit_test_env: dict[str, str]) -> None: + client = OpenAIEmbeddingClient( + azure_endpoint=azure_openai_unit_test_env["AZURE_OPENAI_ENDPOINT"], + api_key=azure_openai_unit_test_env["AZURE_OPENAI_API_KEY"], + ) + + assert isinstance(client.client, AsyncAzureOpenAI) + + @pytest.mark.flaky @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled diff --git a/python/packages/openai/tests/openai/test_openai_shared.py b/python/packages/openai/tests/openai/test_openai_shared.py index b69feb7314..86d43bc43b 100644 --- a/python/packages/openai/tests/openai/test_openai_shared.py +++ b/python/packages/openai/tests/openai/test_openai_shared.py @@ -8,7 +8,11 @@ import pytest from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from agent_framework_openai._shared import AZURE_OPENAI_TOKEN_SCOPE, _resolve_azure_credential_to_token_provider +from agent_framework_openai._shared import ( + AZURE_OPENAI_TOKEN_SCOPE, + _ensure_async_token_provider, + _resolve_azure_credential_to_token_provider, +) class _AsyncTokenCredentialStub(AsyncTokenCredential): @@ -52,3 +56,23 @@ def test_resolve_azure_callable_token_provider_passthrough() -> None: def test_resolve_azure_invalid_credential_raises() -> None: with pytest.raises(ValueError, match="credential"): _resolve_azure_credential_to_token_provider(object()) # type: ignore[arg-type] + + +async def test_ensure_async_token_provider_wraps_sync_provider() -> None: + def sync_provider() -> str: + return "sync-token" + + wrapper = _ensure_async_token_provider(sync_provider) + result = await wrapper() + + assert result == "sync-token" + + +async def test_ensure_async_token_provider_wraps_async_provider() -> None: + async def async_provider() -> str: + return "async-token" + + wrapper = _ensure_async_token_provider(async_provider) + result = await wrapper() + + assert result == "async-token" From f2b215a2f6d4767fd37b17dd33195100ea2e498f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Date: Tue, 21 Apr 2026 22:25:10 +0200 Subject: [PATCH 20/52] .NET [WIP] Foundry Hosted Agents Support (#5312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up tests and sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package Move source and test files from the standalone Hosting.AzureAIResponses project into the Foundry package under a Hosting/ subfolder. This consolidates the Foundry-specific hosting adapter into the main Foundry package. - Source: Microsoft.Agents.AI.Foundry.Hosting namespace - Tests: merged into Foundry.UnitTests/Hosting/ - Conditionally compiled for .NETCoreApp TFMs only (net8.0+) - Deleted standalone Hosting.AzureAIResponses project and test project - Updated sample and solution references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump package version to 0.9.0-hosted.260402.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump OpenTelemetry packages to fix NU1109 downgrade errors - OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0 - OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0 - OpenTelemetry.Extensions.Hosting: already 1.14.0 - OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0 - OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogWarning with IsEnabled check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix model override bug and add client REPL sample - InputConverter: stop propagating request.Model to ChatOptions.ModelId Hosted agents use their own model; client-provided model values like 'hosted-agent' were being passed through and causing server errors. - Add FoundryResponsesRepl sample: interactive CLI client that connects to a Foundry Responses endpoint using ResponsesClient.AsAIAgent() - Bump package version to 0.9.0-hosted.260403.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Catch agent errors and emit response.failed with real error message Previously, unhandled exceptions from agent execution would bubble up to the SDK orchestrator, which emits a generic 'An internal server error occurred.' message — hiding the actual cause (e.g., 401 auth failures, model not found, etc.). Now AgentFrameworkResponseHandler catches non-cancellation exceptions and emits a proper response.failed event containing the real error message, making it visible to clients and in logs. OperationCanceledException still propagates for proper cancellation handling by the SDK. Also bumps package version to 0.9.0-hosted.260403.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Renaming and merging hosting extensions. (#5091) * Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses - Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses - AddFoundryResponses now calls AddResponsesServer() internally - Add MapFoundryResponses() extension on IEndpointRouteBuilder - Update sample and tests to use new API names - Remove redundant AddResponsesServer() and /ready endpoint from sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing numbering in sample. --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address breaking changes in 260408 * Bump hosted internal package version * Add UserAgent middleware tests for Foundry hosting * Hosting Samples update * Hosting Samples update * Hosting Samples update * Hosting Samples update * ChatClientAgent working * Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting * Using updates * Update chat client agent for contributor and devs * Foundry Agent Hosting * Address text rag sample working * Version bump * Adding LocalTools + Workflow samples * Removing extra using samples * Add Hosted-McpTools sample with dual MCP pattern Demonstrates two MCP integration layers in a single hosted agent: - Client-side MCP: McpClient connects to Microsoft Learn, agent handles tool invocations locally (docs_search, code_sample_search, docs_fetch) - Server-side MCP: HostedMcpServerTool delegates tool discovery and invocation to the LLM provider (Responses API), no local connection Includes DevTemporaryTokenCredential for Docker local debugging, Dockerfile.contributor for ProjectReference builds, and the openai/v1 route mapping for AIProjectClient compatibility in Development mode. * .NET: Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix br… (#5287) * Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes - Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21 - Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement) - Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement) - Remove azure-sdk-for-net dev feed (packages now on nuget.org) - Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing small issues. --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Azure AI Foundry Responses hosting adapter Implement Microsoft.Agents.AI.Hosting.AzureAIResponses to host agent-framework AIAgents and workflows within Azure Foundry as hosted agents via the Azure.AI.AgentServer.Responses SDK. - AgentFrameworkResponseHandler: bridges ResponseHandler to AIAgent execution - InputConverter: converts Responses API inputs/history to MEAI ChatMessage - OutputConverter: converts agent response updates to SSE event stream - ServiceCollectionExtensions: DI registration helpers - 336 unit tests across net8.0/net9.0/net10.0 (112 per TFM) - ResponseStreamValidator: SSE protocol validation tool for samples - FoundryResponsesHosting sample app Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump System.ClientModel to 1.10.0 for Azure.Core 1.52.0 compat Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clean up tests and sample formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Azure.AI.AgentServer packages to 1.0.0-alpha.20260401.5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add hosted package version suffix (0.9.0-hosted) to distinguish from mainline Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move Foundry Responses hosting into Microsoft.Agents.AI.Foundry package Move source and test files from the standalone Hosting.AzureAIResponses project into the Foundry package under a Hosting/ subfolder. This consolidates the Foundry-specific hosting adapter into the main Foundry package. - Source: Microsoft.Agents.AI.Foundry.Hosting namespace - Tests: merged into Foundry.UnitTests/Hosting/ - Conditionally compiled for .NETCoreApp TFMs only (net8.0+) - Deleted standalone Hosting.AzureAIResponses project and test project - Updated sample and solution references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump package version to 0.9.0-hosted.260402.2 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump OpenTelemetry packages to fix NU1109 downgrade errors - OpenTelemetry/Api/Exporter.Console/Exporter.InMemory: 1.13.1 -> 1.15.0 - OpenTelemetry.Exporter.OpenTelemetryProtocol: already 1.15.0 - OpenTelemetry.Extensions.Hosting: already 1.14.0 - OpenTelemetry.Instrumentation.AspNetCore/Http: already 1.14.0 - OpenTelemetry.Instrumentation.Runtime: 1.13.0 -> 1.14.0 - Azure.Monitor.OpenTelemetry.Exporter: 1.4.0 -> 1.5.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogWarning with IsEnabled check Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix model override bug and add client REPL sample - InputConverter: stop propagating request.Model to ChatOptions.ModelId Hosted agents use their own model; client-provided model values like 'hosted-agent' were being passed through and causing server errors. - Add FoundryResponsesRepl sample: interactive CLI client that connects to a Foundry Responses endpoint using ResponsesClient.AsAIAgent() - Bump package version to 0.9.0-hosted.260403.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Catch agent errors and emit response.failed with real error message Previously, unhandled exceptions from agent execution would bubble up to the SDK orchestrator, which emits a generic 'An internal server error occurred.' message — hiding the actual cause (e.g., 401 auth failures, model not found, etc.). Now AgentFrameworkResponseHandler catches non-cancellation exceptions and emits a proper response.failed event containing the real error message, making it visible to clients and in logs. OperationCanceledException still propagates for proper cancellation handling by the SDK. Also bumps package version to 0.9.0-hosted.260403.2. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Renaming and merging hosting extensions. (#5091) * Rename AddAgentFrameworkHandler to AddFoundryResponses and add MapFoundryResponses - Rename extension methods: AddAgentFrameworkHandler -> AddFoundryResponses, MapAgentFrameworkHandler -> MapFoundryResponses - AddFoundryResponses now calls AddResponsesServer() internally - Add MapFoundryResponses() extension on IEndpointRouteBuilder - Update sample and tests to use new API names - Remove redundant AddResponsesServer() and /ready endpoint from sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing numbering in sample. --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address breaking changes in 260408 * Bump hosted internal package version * Add UserAgent middleware tests for Foundry hosting * Hosting Samples update * Hosting Samples update * Hosting Samples update * Hosting Samples update * ChatClientAgent working * Adding SessionStorage and SessionManagement, improving samples to align Consumption vs Hosting * Using updates * Update chat client agent for contributor and devs * Foundry Agent Hosting * Address text rag sample working * Version bump * Adding LocalTools + Workflow samples * Removing extra using samples * Add Hosted-McpTools sample with dual MCP pattern Demonstrates two MCP integration layers in a single hosted agent: - Client-side MCP: McpClient connects to Microsoft Learn, agent handles tool invocations locally (docs_search, code_sample_search, docs_fetch) - Server-side MCP: HostedMcpServerTool delegates tool discovery and invocation to the LLM provider (Responses API), no local connection Includes DevTemporaryTokenCredential for Docker local debugging, Dockerfile.contributor for ProjectReference builds, and the openai/v1 route mapping for AIProjectClient compatibility in Development mode. * Bump Azure.AI.AgentServer packages to 1.0.0-beta.1/beta.21 and fix breaking API changes - Azure.AI.AgentServer.Core: 1.0.0-beta.11 -> 1.0.0-beta.21 - Azure.AI.AgentServer.Invocations: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.AI.AgentServer.Responses: 1.0.0-alpha.20260408.4 -> 1.0.0-beta.1 - Azure.Identity: 1.20.0 -> 1.21.0 (transitive requirement) - Azure.Core: 1.52.0 -> 1.53.0 (transitive requirement) - Remove azure-sdk-for-net dev feed (packages now on nuget.org) - Fix OutputConverter for new builder API (auto-tracked children, split EmitTextDone/EmitDone) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fixing small issues. * Fix IDE0009: add 'this' qualification in DevTemporaryTokenCredential Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix IDE0009: add 'this' qualification in all HostedAgentsV2 samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CHARSET: add UTF-8 BOM to Hosted-LocalTools and Hosted-Workflows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dotnet format: add Async suffix to test methods (IDE1006), fix encoding and style Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Register AgentSessionStore in test DI setups Add InMemoryAgentSessionStore registration to all ServiceCollection setups in AgentFrameworkResponseHandlerTests and WorkflowIntegrationTests. This is needed after the AgentSessionStore infrastructure was introduced in the responses-hosting feature. Tests still have NotImplementedException stubs for CreateSessionCoreAsync which will be fixed when the session infrastructure is fully available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Invocations protocol samples (hosted echo agent + client) (#5278) Add Hosted-Invocations-EchoAgent: a minimal echo agent hosted via the Invocations protocol (POST /invocations) using AddInvocationsServer and MapInvocationsServer, bridged to an Agent Framework AIAgent through a custom InvocationHandler. Add SimpleInvocationsAgent: a console REPL client that wraps HttpClient calls to the /invocations endpoint in a custom InvocationsAIAgent, demonstrating programmatic consumption of the Invocations protocol. Both samples default to port 8088 for consistency with other hosted agent samples. * Restructure FoundryHostedAgents samples into invocations/ and responses/ Align dotnet hosted agent samples with the Python side (PR #5281) by reorganizing the directory structure: - Remove HostedAgentsV1 entirely (old API pattern) - Split HostedAgentsV2 into invocations/ and responses/ based on protocol - Move Using-Samples accordingly (SimpleAgent to responses, SimpleInvocationsAgent to invocations) - Update slnx with new project paths and add previously missing invocations projects - Update README cd paths from HostedAgentsV2 to invocations or responses - Rename .env.local to .env.example to match Python naming convention - Fix format violations in newly included invocations projects * Remove launchSettings, use .env for port configuration - Delete all launchSettings.json files (port 8088 now comes from ASPNETCORE_URLS in .env) - Add DotNetEnv to Hosted-Invocations-EchoAgent so it loads .env like the responses samples - Create .env.example for EchoAgent with ASPNETCORE_URLS and ASPNETCORE_ENVIRONMENT - Add AGENT_NAME to ChatClientAgent and FoundryAgent .env.example (required by those samples) - Add AZURE_BEARER_TOKEN=DefaultAzureCredential to all .env.example files - Update DevTemporaryTokenCredential in all 6 samples to treat the sentinel value as unavailable, allowing ChainedTokenCredential to fall through to DefaultAzureCredential - Update EchoAgent README with Configuration section * Use placeholder for AGENT_NAME in Hosted-FoundryAgent .env.example * Move FoundryResponsesHosting to responses/Hosted-WorkflowHandoff, use GetResponsesClient * Rename Hosted-Workflows to Hosted-Workflow-Simple, Hosted-WorkflowHandoff to Hosted-Workflow-Handoff * Remove FoundryResponsesRepl and empty FoundryResponsesHosting directory * Add Dockerfiles, README, agent yamls and bearer token support to Hosted-Workflow-Handoff - Add Dockerfile and Dockerfile.contributor for Docker-based testing - Add agent.yaml and agent.manifest.yaml with triage-workflow as primary agent - Add README.md following sibling pattern, noting Azure OpenAI vs Foundry endpoint - Add DevTemporaryTokenCredential and ChainedTokenCredential for Docker auth - Register triage-workflow as non-keyed default so azd invoke works without model - Update .env.example with AZURE_BEARER_TOKEN sentinel - Add .gitignore to 04-hosting to suppress VS-generated launchSettings.json - Fix docker run image name in Hosted-Workflow-Simple README * Fix AgentFrameworkResponseHandlerTests: implement session methods in test mock agents * .NET: Auto-instrument resolved AIAgents with OpenTelemetry for Foundry Hosted Agents (#5316) * Auto-instrument resolved AIAgents with OpenTelemetry using Core ResponsesSourceName * Add OTel telemetry capture tests for Foundry hosted agent handler * Net: Prepare Foundry Preview Release (#5336) * Prepare Foundry preview release 1.2.0-preview.* Bump VersionPrefix to 1.2.0 and update the preview stamp date. Invert packaging opt-in so only the Foundry preview set produces NuGet packages: - Microsoft.Agents.AI.Abstractions - Microsoft.Agents.AI - Microsoft.Agents.AI.Workflows - Microsoft.Agents.AI.Workflows.Generators - Microsoft.Agents.AI.Foundry Flip IsReleased=false on the preview set so they pick up the -preview.YYMMDD.N suffix. Gate GeneratePackageOnBuild on IsPackable=true. Remove the global IsPackable=true from nuget-package.props so the repo-level default (false) applies to everything else. * Lower preview VersionPrefix to 0.0.1 Retroactive preview publish: bump VersionPrefix and GitTag from 1.2.0 to 0.0.1 so the 5 Foundry preview packages emit as 0.0.1-preview.260417.1. * Net: Publish all packages as 0.0.1-preview.260417.2 (#5341) Revises the Foundry pre-release approach to publish ALL normally packable src projects as preview packages stamped 0.0.1-preview.260417.2, including projects previously flagged IsReleased=true or with a non-default VersionSuffix (rc/alpha). nuget-package.props: - Collapse the four conditional PackageVersion expressions (IsReleaseCandidate, VersionSuffix, default preview, IsReleased stable) into a single unconditional 0.0.1-preview.260417.2. On this preview-only branch every package ships with the same pre-release stamp regardless of per-project flags. - Restore the global IsPackable=true default (offsetting the repo-wide IsPackable=false in Directory.Build.props). Projects that opt out (Mem0, Declarative) already set IsPackable=false AFTER importing this file so they remain non-packable. - Remove the IsReleased-gated EnablePackageValidation line. Package validation does not apply to a 0.0.1 preview. csproj reverts (Abstractions, Agents.AI, Workflows, Workflows.Generators, Foundry): - Revert the IsPackable=true opt-in block introduced in #5336 (now redundant since the props default is true again). - Restore IsReleased=true to its pre-PR value. The setting is now a no-op because the props no longer branches on it. * Bump preview version to 260420.1 and fix AgentServer package deps (#5367) - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agents toolbox support (#5368) * feat: Add Foundry Toolbox (MCP) support to AgentFrameworkResponseHandler Adds support for Foundry Toolsets MCP proxy integration in the hosted agent response handler. Toolsets connect at startup via IHostedService, gating the readiness probe per spec §3.1. MCP tools are injected into every request's ChatOptions and OAuth consent errors (-32006) are intercepted and surfaced as mcp_approval_request + incomplete SSE events. New files: - FoundryToolboxOptions.cs: configuration POCO for toolset names and API version - FoundryToolboxBearerTokenHandler.cs: DelegatingHandler with Azure Bearer token auth, Foundry-Features header injection, and 3x exponential backoff on 429/5xx - McpConsentContext.cs: AsyncLocal-based per-request consent state shared between the tool wrapper and the response handler - ConsentAwareMcpClientTool.cs: AIFunction wrapper that catches -32006 errors and signals consent via shared state and linked CancellationTokenSource - FoundryToolboxService.cs: IHostedService that creates McpClient per toolset at startup and exposes cached tools Modified files: - AgentFrameworkResponseHandler.cs: injects toolbox tools into ChatOptions, sets up linked CTS consent interception, emits mcp_approval_request on -32006 - ServiceCollectionExtensions.cs: adds AddFoundryToolboxes(params string[]) extension - Microsoft.Agents.AI.Foundry.csproj: adds ModelContextProtocol and Azure.Identity dependencies under NETCoreApp condition Sample: - Hosted-Toolbox: minimal hosted agent sample using AddFoundryToolboxes * Rename toolset to toolbox in user-facing API; rename ConsentAwareMcpClientTool to ConsentAwareMcpClientAIFunction * Add HostedMcpToolboxAITool for client-selectable Foundry toolboxes Introduces HostedMcpToolboxAITool, a marker tool subclassing HostedMcpServerTool that rides the OpenAI Responses 'mcp' wire format to let clients request a specific Foundry toolbox per request. - New FoundryAITool.CreateHostedMcpToolbox(name, version?) factory. - FoundryToolboxOptions.StrictMode (default true) rejects unregistered toolboxes; set to false to allow lazy-open on first use. - FoundryToolboxService.GetToolboxToolsAsync(name, version?) resolves cached or lazy-opened MCP tools. - AgentFrameworkResponseHandler parses request.Tools for foundry-toolbox://name[?version=v] markers and injects resolved tools per request, merging with pre-registered ones. - Unit tests for marker parsing and strict-mode resolution. * Bump Azure.AI.Projects to 2.1.0-alpha; add ToolboxRecord/ToolboxVersion factory overloads + tests * Fix PR review issues: retry off-by-one, URI encoding, docs, tests, build - Fix off-by-one in FoundryToolboxBearerTokenHandler retry loop (4 attempts → 3) - URI-encode version parameter in HostedMcpToolboxAITool.BuildAddress - Add XML doc clarifying version pinning is reserved for future use - Add comment clarifying AddHostedService deduplication safety - Fix DevTemporaryTokenCredential expiry to use DateTimeOffset.MaxValue - Fix AgentCard ambiguity in A2AServer sample with using alias - Add 18 new unit tests for retry handler and ReadMcpToolboxMarkers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com> Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5371) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5374) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Hosted agent adapter (#5406) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version * Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Hosted agent adapter (#5408) * Bump preview version to 260420.1 and fix AgentServer package deps - Bump PackageVersion to 0.0.1-preview.260420.1 - Bump Azure.AI.AgentServer.Core beta.21 -> beta.22 (required by Azure.AI.AgentServer.Responses beta.3) - Replace AgentHostTelemetry.ResponsesSourceName with local constant (type made internal in AgentServer.Core beta.22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix CA1873: guard LogError with IsEnabled check in FoundryToolboxService Wrap the LogError call with an IsEnabled(LogLevel.Error) guard to satisfy the CA1873 analyzer rule which flags potentially expensive argument evaluation when logging is disabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bumping NuGet version * Restore conditional versioning, remove dev feed, bump Azure.AI.Projects to beta.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #5312 review comments - Add comment explaining NU1903 suppression (Microsoft.Bcl.Memory transitive vuln) - Remove NU1903 from sample/test projects where not needed - Fix Dockerfile ENTRYPOINT mismatch in Hosted-Workflow-Simple - Align agent name to 'hosted-workflow-simple' in agent.yaml and README - Fix Hosted-McpTools README: replace GitHub PAT refs with Microsoft Learn - Fix session persistence: only persist when client provides conversation ID - Upgrade IsNullOrEmpty to IsNullOrWhiteSpace for session ID checks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Split Foundry into stable V1 and preview Hosting package Extract hosted agent functionality from Microsoft.Agents.AI.Foundry into a new Microsoft.Agents.AI.Foundry.Hosting preview package. This resolves NU5104 build errors caused by the stable Foundry package depending on prerelease Azure SDK packages (Azure.AI.AgentServer.Responses, Azure.AI.Projects beta). Changes: - Create Microsoft.Agents.AI.Foundry.Hosting with VersionSuffix=preview, targeting .NET Core only (net8.0/9.0/10.0) - Move all Hosting/ source files to the new project - Move ToolboxRecord/ToolboxVersion overloads to FoundryAIToolExtensions - Revert Azure.AI.Projects to 2.0.0 in Directory.Packages.props; Hosting uses VersionOverride for 2.1.0-beta.1 - Clean V1 Foundry csproj: remove beta deps, ASP.NET Core ref, hosting conditionals - Update 8 hosted agent sample projects to reference Foundry.Hosting - Split unit tests: ToolboxRecord/ToolboxVersion tests moved to Hosting/ - Add Foundry.Hosting to solution file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review comments: experimental attrs, doc fixes, token propagation - Add [Experimental(OPENAI001)] to all 7 public Hosting types per reviewer request - Fix McpConsentContext XML doc: 'Thread-static' -> 'Async-local' (AsyncLocal flows with ExecutionContext, not thread-static) - Expand UserAgentMiddleware test regex to match prerelease versions (e.g. 1.0.0-rc.4) - Propagate CancellationToken in AgentFrameworkResponseHandler session save Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary MEAI001 suppression from stable Foundry package MEAI001 was a leftover from when Hosting code lived in the same project. The stable V1 Foundry package builds clean without it, and suppressing experimental diagnostics in a released package can hide unintentional exposure of experimental APIs to consumers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Foundry.Hosting to release solution filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ben Thomas --- .gitignore | 4 + dotnet/.gitignore | 9 +- dotnet/Directory.Packages.props | 25 +- dotnet/agent-framework-dotnet.slnx | 60 +- dotnet/agent-framework-release.slnf | 1 + dotnet/nuget.config | 4 +- dotnet/nuget/nuget-package.props | 3 +- dotnet/samples/04-hosting/.gitignore | 1 + .../Hosted-Invocations-EchoAgent/.env.example | 2 + .../Hosted-Invocations-EchoAgent/Dockerfile | 17 + .../Dockerfile.contributor | 19 + .../EchoAIAgent.cs | 85 ++ .../EchoInvocationHandler.cs | 32 + .../Hosted-Invocations-EchoAgent.csproj | 30 + .../Hosted-Invocations-EchoAgent/Program.cs | 28 + .../Hosted-Invocations-EchoAgent/README.md | 76 ++ .../agent.manifest.yaml | 27 + .../Hosted-Invocations-EchoAgent/agent.yaml | 9 + .../InvocationsAIAgent.cs | 129 ++ .../SimpleInvocationsAgent/Program.cs | 61 + .../SimpleInvocationsAgent.csproj | 22 + .../Hosted-ChatClientAgent/.env.example | 6 + .../Hosted-ChatClientAgent/Dockerfile | 17 + .../Dockerfile.contributor | 19 + .../HostedChatClientAgent.csproj | 32 + .../Hosted-ChatClientAgent/Program.cs | 98 ++ .../Hosted-ChatClientAgent/README.md | 109 ++ .../agent.manifest.yaml | 28 + .../Hosted-ChatClientAgent/agent.yaml | 9 + .../Hosted-FoundryAgent/.env.example | 5 + .../responses/Hosted-FoundryAgent/Dockerfile | 17 + .../Dockerfile.contributor | 19 + .../HostedFoundryAgent.csproj | 32 + .../responses/Hosted-FoundryAgent/Program.cs | 91 ++ .../responses/Hosted-FoundryAgent/README.md | 121 ++ .../Hosted-FoundryAgent/agent.manifest.yaml | 28 + .../responses/Hosted-FoundryAgent/agent.yaml | 9 + .../responses/Hosted-LocalTools/.env.example | 5 + .../responses/Hosted-LocalTools/Dockerfile | 17 + .../Hosted-LocalTools/Dockerfile.contributor | 19 + .../Hosted-LocalTools/HostedLocalTools.csproj | 32 + .../responses/Hosted-LocalTools/Program.cs | 164 +++ .../responses/Hosted-LocalTools/README.md | 113 ++ .../Hosted-LocalTools/agent.manifest.yaml | 29 + .../responses/Hosted-LocalTools/agent.yaml | 9 + .../responses/Hosted-McpTools/.env.example | 5 + .../responses/Hosted-McpTools/Dockerfile | 17 + .../Hosted-McpTools/Dockerfile.contributor | 18 + .../Hosted-McpTools/HostedMcpTools.csproj | 33 + .../responses/Hosted-McpTools/Program.cs | 130 ++ .../responses/Hosted-McpTools/README.md | 83 ++ .../Hosted-McpTools/agent.manifest.yaml | 30 + .../responses/Hosted-McpTools/agent.yaml | 9 + .../responses/Hosted-TextRag/.env.example | 5 + .../responses/Hosted-TextRag/Dockerfile | 17 + .../Hosted-TextRag/Dockerfile.contributor | 19 + .../Hosted-TextRag/HostedTextRag.csproj | 34 + .../responses/Hosted-TextRag/Program.cs | 130 ++ .../responses/Hosted-TextRag/README.md | 116 ++ .../Hosted-TextRag/agent.manifest.yaml | 30 + .../responses/Hosted-TextRag/agent.yaml | 9 + .../Hosted-Toolbox/HostedToolbox.csproj | 32 + .../responses/Hosted-Toolbox/Program.cs | 113 ++ .../Hosted-Workflow-Handoff/.env.example | 5 + .../Hosted-Workflow-Handoff/Dockerfile | 17 + .../Dockerfile.contributor | 19 + .../HostedWorkflowHandoff.csproj | 42 + .../Hosted-Workflow-Handoff/Pages.cs | 470 +++++++ .../Hosted-Workflow-Handoff/Program.cs | 221 ++++ .../Hosted-Workflow-Handoff/README.md | 126 ++ .../ResponseStreamValidator.cs | 601 +++++++++ .../agent.manifest.yaml | 30 + .../Hosted-Workflow-Handoff/agent.yaml | 9 + .../Hosted-Workflow-Simple/.env.example | 5 + .../Hosted-Workflow-Simple/Dockerfile | 17 + .../Dockerfile.contributor | 18 + .../HostedWorkflowSimple.csproj | 36 + .../Hosted-Workflow-Simple/Program.cs | 97 ++ .../Hosted-Workflow-Simple/README.md | 109 ++ .../agent.manifest.yaml | 29 + .../Hosted-Workflow-Simple/agent.yaml | 9 + .../Using-Samples/SimpleAgent/Program.cs | 115 ++ .../SimpleAgent/SimpleAgent.csproj | 24 + .../A2AServer/HostAgentFactory.cs | 1 + .../AgentThreadAndHITL.csproj | 69 -- .../AgentThreadAndHITL/Dockerfile | 20 - .../AgentThreadAndHITL/Program.cs | 41 - .../HostedAgents/AgentThreadAndHITL/README.md | 46 - .../AgentThreadAndHITL/agent.yaml | 28 - .../AgentThreadAndHITL/run-requests.http | 70 -- .../AgentWithHostedMCP.csproj | 68 -- .../AgentWithHostedMCP/Dockerfile | 20 - .../AgentWithHostedMCP/Program.cs | 40 - .../HostedAgents/AgentWithHostedMCP/README.md | 45 - .../AgentWithHostedMCP/agent.yaml | 31 - .../AgentWithHostedMCP/run-requests.http | 32 - .../AgentWithLocalTools/.dockerignore | 24 - .../AgentWithLocalTools.csproj | 70 -- .../AgentWithLocalTools/Dockerfile | 20 - .../AgentWithLocalTools/Program.cs | 132 -- .../AgentWithLocalTools/README.md | 39 - .../AgentWithLocalTools/agent.yaml | 29 - .../AgentWithLocalTools/run-requests.http | 52 - .../AgentWithTextSearchRag.csproj | 68 -- .../AgentWithTextSearchRag/Dockerfile | 20 - .../AgentWithTextSearchRag/Program.cs | 79 -- .../AgentWithTextSearchRag/README.md | 43 - .../AgentWithTextSearchRag/agent.yaml | 31 - .../AgentWithTextSearchRag/run-requests.http | 30 - .../AgentsInWorkflows.csproj | 68 -- .../HostedAgents/AgentsInWorkflows/Dockerfile | 20 - .../HostedAgents/AgentsInWorkflows/Program.cs | 40 - .../HostedAgents/AgentsInWorkflows/README.md | 28 - .../HostedAgents/AgentsInWorkflows/agent.yaml | 28 - .../AgentsInWorkflows/run-requests.http | 30 - .../HostedAgents/FoundryMultiAgent/Dockerfile | 20 - .../FoundryMultiAgent.csproj | 76 -- .../HostedAgents/FoundryMultiAgent/Program.cs | 51 - .../HostedAgents/FoundryMultiAgent/README.md | 168 --- .../HostedAgents/FoundryMultiAgent/agent.yaml | 31 - .../appsettings.Development.json | 4 - .../FoundryMultiAgent/run-requests.http | 34 - .../FoundrySingleAgent/Dockerfile | 20 - .../FoundrySingleAgent.csproj | 67 - .../FoundrySingleAgent/Program.cs | 132 -- .../HostedAgents/FoundrySingleAgent/README.md | 167 --- .../FoundrySingleAgent/agent.yaml | 32 - .../FoundrySingleAgent/run-requests.http | 52 - .../05-end-to-end/HostedAgents/README.md | 100 -- .../AgentFrameworkResponseHandler.cs | 379 ++++++ .../AgentSessionStore.cs | 49 + .../ConsentAwareMcpClientAIFunction.cs | 70 ++ .../FoundryAIToolExtensions.cs | 51 + .../FoundryToolboxBearerTokenHandler.cs | 109 ++ .../FoundryToolboxOptions.cs | 43 + .../FoundryToolboxService.cs | 269 ++++ .../InMemoryAgentSessionStore.cs | 56 + .../InputConverter.cs | 353 ++++++ .../McpConsentContext.cs | 45 + ...Microsoft.Agents.AI.Foundry.Hosting.csproj | 50 + .../OutputConverter.cs | 349 ++++++ .../ServiceCollectionExtensions.cs | 261 ++++ .../AzureAIProjectChatClientExtensions.cs | 7 +- .../FoundryAITool.cs | 10 + .../HostedMcpToolboxAITool.cs | 156 +++ .../Microsoft.Agents.AI.Foundry.csproj | 1 + .../HostedMcpToolboxAIToolTests.cs | 96 ++ ...tFrameworkResponseHandlerTelemetryTests.cs | 237 ++++ .../AgentFrameworkResponseHandlerTests.cs | 870 +++++++++++++ .../Hosting/FoundryAIToolExtensionsTests.cs | 76 ++ .../FoundryToolboxBearerTokenHandlerTests.cs | 185 +++ .../Hosting/FoundryToolboxServiceTests.cs | 69 ++ .../Hosting/InputConverterTests.cs | 767 ++++++++++++ .../Hosting/OutputConverterTests.cs | 1081 +++++++++++++++++ .../ServiceCollectionExtensionsTests.cs | 96 ++ .../Hosting/UserAgentMiddlewareTests.cs | 134 ++ .../Hosting/WorkflowIntegrationTests.cs | 510 ++++++++ ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 27 +- 158 files changed, 10872 insertions(+), 2351 deletions(-) create mode 100644 dotnet/samples/04-hosting/.gitignore create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoAIAgent.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Pages.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/ResponseStreamValidator.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile.contributor create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs create mode 100644 dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http delete mode 100644 dotnet/samples/05-end-to-end/HostedAgents/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/McpConsentContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryAIToolExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs diff --git a/.gitignore b/.gitignore index c846efea7b..da436cd090 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,10 @@ celerybeat.pid .venv env/ venv/ + +# Foundry agent CLI (contains secrets, auto-generated) +.foundry-agent.json +.foundry-agent-build.log ENV/ env.bak/ venv.bak/ diff --git a/dotnet/.gitignore b/dotnet/.gitignore index ce1409abe9..572680831e 100644 --- a/dotnet/.gitignore +++ b/dotnet/.gitignore @@ -402,4 +402,11 @@ FodyWeavers.xsd *.msp # JetBrains Rider -*.sln.iml \ No newline at end of file +*.sln.iml + +# Foundry agent CLI config (contains secrets, auto-generated) +.foundry-agent.json +.foundry-agent-build.log + +# Pre-published output for Docker builds +out/ \ No newline at end of file diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 63800e8a33..46e0d61924 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -22,11 +22,16 @@ + + + - - + + + + @@ -51,15 +56,15 @@ - - - - - + + + + + - - - + + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 96c2411e3b..b3a95ea1f6 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,4 +1,4 @@ - + @@ -283,7 +283,44 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -338,15 +375,6 @@ - - - - - - - - - @@ -508,13 +536,13 @@ - - + + @@ -536,11 +564,10 @@ - + - @@ -557,12 +584,11 @@ - - + diff --git a/dotnet/agent-framework-release.slnf b/dotnet/agent-framework-release.slnf index 8cc97dc8cb..00e3e6571e 100644 --- a/dotnet/agent-framework-release.slnf +++ b/dotnet/agent-framework-release.slnf @@ -9,6 +9,7 @@ "src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj", "src\\Microsoft.Agents.AI.AzureAI.Persistent\\Microsoft.Agents.AI.AzureAI.Persistent.csproj", "src\\Microsoft.Agents.AI.Foundry\\Microsoft.Agents.AI.Foundry.csproj", + "src\\Microsoft.Agents.AI.Foundry.Hosting\\Microsoft.Agents.AI.Foundry.Hosting.csproj", "src\\Microsoft.Agents.AI.CopilotStudio\\Microsoft.Agents.AI.CopilotStudio.csproj", "src\\Microsoft.Agents.AI.CosmosNoSql\\Microsoft.Agents.AI.CosmosNoSql.csproj", "src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj", diff --git a/dotnet/nuget.config b/dotnet/nuget.config index 76d943ce16..128d95e590 100644 --- a/dotnet/nuget.config +++ b/dotnet/nuget.config @@ -1,4 +1,4 @@ - + @@ -9,4 +9,4 @@ - \ No newline at end of file + diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 9baf1e5885..47e89a5868 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -30,7 +30,8 @@ low - + + Microsoft Microsoft diff --git a/dotnet/samples/04-hosting/.gitignore b/dotnet/samples/04-hosting/.gitignore new file mode 100644 index 0000000000..324c8dcfb3 --- /dev/null +++ b/dotnet/samples/04-hosting/.gitignore @@ -0,0 +1 @@ +**/Properties/launchSettings.json diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/.env.example new file mode 100644 index 0000000000..46a6ae748c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/.env.example @@ -0,0 +1,2 @@ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile new file mode 100644 index 0000000000..24585dec12 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile.contributor new file mode 100644 index 0000000000..91a403c26c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Abstractions source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +# docker run --rm -p 8088:8088 hosted-invocations-echo-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedInvocationsEchoAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoAIAgent.cs new file mode 100644 index 0000000000..ccbfe72781 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoAIAgent.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// A minimal that echoes the user's input text back as the response. +/// No LLM or external service is required. +/// +public sealed class EchoAIAgent : AIAgent +{ + /// + public override string Name => "echo-agent"; + + /// + public override string Description => "An agent that echoes back the input message."; + + /// + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, $"Echo: {inputText}")); + return Task.FromResult(response); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var inputText = GetInputText(messages); + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent($"Echo: {inputText}")], + }; + + await Task.CompletedTask; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new EchoAgentSession()); + + private static string GetInputText(IEnumerable messages) + { + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + return message.Text ?? string.Empty; + } + } + + return string.Empty; + } + + /// + /// Minimal session for the echo agent. No state is persisted. + /// + private sealed class EchoAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs new file mode 100644 index 0000000000..f0101a57f4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/EchoInvocationHandler.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using Microsoft.Agents.AI; + +namespace HostedInvocationsEchoAgent; + +/// +/// An that reads the request body as plain text, +/// passes it to the , and writes the response back. +/// +public sealed class EchoInvocationHandler(EchoAIAgent agent) : InvocationHandler +{ + /// + public override async Task HandleAsync( + HttpRequest request, + HttpResponse response, + InvocationContext context, + CancellationToken cancellationToken) + { + // Read the raw text from the request body. + using var reader = new StreamReader(request.Body); + var input = await reader.ReadToEndAsync(cancellationToken); + + // Run the echo agent with the input text. + var agentResponse = await agent.RunAsync(input, cancellationToken: cancellationToken); + + // Write the agent response text back to the HTTP response. + response.ContentType = "text/plain"; + await response.WriteAsync(agentResponse.Text, cancellationToken); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj new file mode 100644 index 0000000000..d925172007 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Hosted-Invocations-EchoAgent.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + HostedInvocationsEchoAgent + HostedInvocationsEchoAgent + $(NoWarn); + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Program.cs new file mode 100644 index 0000000000..d5944560ae --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/Program.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.AgentServer.Invocations; +using DotNetEnv; +using HostedInvocationsEchoAgent; +using Microsoft.Agents.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var builder = WebApplication.CreateBuilder(args); + +// Register the echo agent as a singleton (no LLM needed). +builder.Services.AddSingleton(); + +// Register the Invocations SDK services and wire the handler. +builder.Services.AddInvocationsServer(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +// Map the Invocations protocol endpoints: +// POST /invocations — invoke the agent +// GET /invocations/{id} — get result (not used by this sample) +// POST /invocations/{id}/cancel — cancel (not used by this sample) +app.MapInvocationsServer(); + +app.Run(); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md new file mode 100644 index 0000000000..5fcfddab22 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/README.md @@ -0,0 +1,76 @@ +# Hosted-Invocations-EchoAgent + +A minimal echo agent hosted as a Foundry Hosted Agent using the **Invocations protocol**. The agent reads the request body as plain text, passes it through a custom `EchoAIAgent`, and writes the echoed text back in the response. No LLM or Azure credentials are required. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) + +## Configuration + +Copy the template: + +```bash +cp .env.example .env +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello, world!" +``` + +Expected response: + +``` +Echo: Hello, world! +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-invocations-echo-agent . +``` + +### 3. Run the container + +```bash +docker run --rm -p 8088:8088 hosted-invocations-echo-agent +``` + +### 4. Test it + +```bash +curl -X POST http://localhost:8088/invocations \ + -H "Content-Type: text/plain" \ + -d "Hello from Docker!" +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `Hosted-Invocations-EchoAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml new file mode 100644 index 0000000000..09e4b0f885 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.manifest.yaml @@ -0,0 +1,27 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-invocations-echo-agent +displayName: "Hosted Invocations Echo Agent" + +description: > + A minimal echo agent hosted as a Foundry Hosted Agent using the Invocations + protocol. Reads the request body as plain text, echoes it back in the response. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Invocations Protocol + - Agent Framework + +template: + name: hosted-invocations-echo-agent + kind: hosted + protocols: + - protocol: invocations + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.yaml new file mode 100644 index 0000000000..001a19f0ac --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Hosted-Invocations-EchoAgent/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-invocations-echo-agent +protocols: + - protocol: invocations + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs new file mode 100644 index 0000000000..db291458c2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/InvocationsAIAgent.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Runtime.CompilerServices; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI; + +/// +/// An that invokes a remote agent hosted with the Invocations protocol +/// by sending plain-text HTTP POST requests to the /invocations endpoint. +/// +public sealed class InvocationsAIAgent : AIAgent +{ + private readonly HttpClient _httpClient; + private readonly Uri _invocationsUri; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The base URI of the hosted agent (e.g., http://localhost:8089). + /// The /invocations path is appended automatically. + /// + /// Optional to use. If , a new instance is created. + /// Optional name for the agent. + /// Optional description for the agent. + public InvocationsAIAgent( + Uri agentEndpoint, + HttpClient? httpClient = null, + string? name = null, + string? description = null) + { + ArgumentNullException.ThrowIfNull(agentEndpoint); + + this._httpClient = httpClient ?? new HttpClient(); + + // Ensure the base URI ends with a slash so that combining works correctly. + var baseUri = agentEndpoint.AbsoluteUri.EndsWith('/') + ? agentEndpoint + : new Uri(agentEndpoint.AbsoluteUri + "/"); + this._invocationsUri = new Uri(baseUri, "invocations"); + + this.Name = name ?? "invocations-agent"; + this.Description = description ?? "An agent that calls a remote Invocations protocol endpoint."; + } + + /// + public override string? Name { get; } + + /// + public override string? Description { get; } + + /// + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var inputText = GetLastUserText(messages); + var responseText = await this.SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + return new AgentResponse(new ChatMessage(ChatRole.Assistant, responseText)); + } + + /// + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + // The Invocations protocol returns a complete response (no SSE streaming), + // so we yield a single update with the full text. + var inputText = GetLastUserText(messages); + var responseText = await this.SendInvocationAsync(inputText, cancellationToken).ConfigureAwait(false); + + yield return new AgentResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(responseText)], + }; + } + + /// + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + /// + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(JsonSerializer.SerializeToElement(new { }, jsonSerializerOptions)); + + /// + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions = null, + CancellationToken cancellationToken = default) + => new(new InvocationsAgentSession()); + + private async Task SendInvocationAsync(string input, CancellationToken cancellationToken) + { + using var content = new StringContent(input, System.Text.Encoding.UTF8, "text/plain"); + using var response = await this._httpClient.PostAsync(this._invocationsUri, content, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + private static string GetLastUserText(IEnumerable messages) + { + string? lastUserText = null; + foreach (var message in messages) + { + if (message.Role == ChatRole.User) + { + lastUserText = message.Text; + } + } + + return lastUserText ?? string.Empty; + } + + /// + /// Minimal session for the invocations agent. No state is persisted. + /// + private sealed class InvocationsAgentSession : AgentSession; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/Program.cs new file mode 100644 index 0000000000..915e73737d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/Program.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using DotNetEnv; +using Microsoft.Agents.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +// Create an agent that calls the remote Invocations endpoint. +InvocationsAIAgent agent = new(agentEndpoint); + +// REPL +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Simple Invocations Agent Sample + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj new file mode 100644 index 0000000000..126bfef63c --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/invocations/Using-Samples/SimpleInvocationsAgent/SimpleInvocationsAgent.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + false + SimpleInvocationsAgentClient + simple-invocations-agent-client + $(NoWarn);NU1605 + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example new file mode 100644 index 0000000000..984e8625cf --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/.env.example @@ -0,0 +1,6 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AGENT_NAME=hosted-chat-client-agent +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile new file mode 100644 index 0000000000..6f1be8ee8e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor new file mode 100644 index 0000000000..200f674bdd --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-chat-client-agent . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj new file mode 100644 index 0000000000..0bccd317f6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + HostedChatClientAgent + HostedChatClientAgent + $(NoWarn); + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs new file mode 100644 index 0000000000..ee86bbae1b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/Program.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// Create the agent via the AI project client using the Responses API. +AIAgent agent = new AIProjectClient(projectEndpoint, credential) + .AsAIAgent( + model: deployment, + instructions: """ + You are a helpful AI assistant hosted as a Foundry Hosted Agent. + You can help with a wide range of tasks including answering questions, + providing explanations, brainstorming ideas, and offering guidance. + Be concise, clear, and helpful in your responses. + """, + name: agentName, + description: "A simple general-purpose AI assistant"); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// +/// When debugging and testing a hosted agent in a local Docker container, Azure CLI +/// and other interactive credentials are not available. This credential reads a +/// pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// In production, the Foundry platform injects a managed identity automatically. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return this.GetAccessToken(); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(this.GetAccessToken()); + } + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md new file mode 100644 index 0000000000..ace8892572 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/README.md @@ -0,0 +1,109 @@ +# Hosted-ChatClientAgent + +A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent +dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-chat-client-agent . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-chat-client-agent \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-chat-client-agent +``` + +> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": "hosted-chat-client-agent"}' +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml new file mode 100644 index 0000000000..58a07d8bb3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-chat-client-agent +displayName: "Hosted Chat Client Agent" + +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent + using the Agent Framework instance hosting pattern. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + +template: + name: hosted-chat-client-agent + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml new file mode 100644 index 0000000000..0a97abc35a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-chat-client-agent +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example new file mode 100644 index 0000000000..c72380d125 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AGENT_NAME= +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile new file mode 100644 index 0000000000..eda1f7e1e9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile.contributor new file mode 100644 index 0000000000..2b6a2dbbc4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-foundry-agent . +# docker run --rm -p 8088:8088 -e AGENT_NAME= -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-foundry-agent +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedFoundryAgent.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj new file mode 100644 index 0000000000..5e0d9bcbf9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + HostedFoundryAgent + HostedFoundryAgent + $(NoWarn); + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs new file mode 100644 index 0000000000..b593b16671 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/Program.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI.Foundry; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +var aiProjectClient = new AIProjectClient(projectEndpoint, credential); + +// Retrieve the Foundry-managed agent by name (latest version). +ProjectsAgentRecord agentRecord = await aiProjectClient + .AgentAdministrationClient.GetAgentAsync(agentName); + +FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// +/// When debugging and testing a hosted agent in a local Docker container, Azure CLI +/// and other interactive credentials are not available. This credential reads a +/// pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// In production, the Foundry platform injects a managed identity automatically. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return this.GetAccessToken(); + } + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + { + return new ValueTask(this.GetAccessToken()); + } + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md new file mode 100644 index 0000000000..8265a80632 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/README.md @@ -0,0 +1,121 @@ +# Hosted-FoundryAgent + +A hosted agent that delegates to a **Foundry-managed agent definition**. Instead of defining the model, instructions, and tools inline in code, this sample retrieves an existing agent registered in the Foundry platform via `AIProjectClient.AsAIAgent(agentRecord)` and hosts it using the Responses protocol. + +This is the **Foundry hosting** pattern — the agent's behavior is configured in the platform (via Foundry UI, CLI, or API), and this server simply wraps and serves it. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a **registered agent** (created via Foundry UI, CLI, or API) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +You also need to set `AGENT_NAME` — the name of the Foundry-managed agent to host. This is injected automatically by the Foundry platform when deployed. For local development, pass it as an environment variable. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent +AGENT_NAME= dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": ""}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-foundry-agent . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME= \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-foundry-agent +``` + +> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration. + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Hello!" +``` + +Or with curl (specifying the agent name explicitly): + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Hello!", "model": ""}' +``` + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedFoundryAgent.csproj` for the `PackageReference` alternative. + +## How it differs from Hosted-ChatClientAgent + +| | Hosted-ChatClientAgent | Hosted-FoundryAgent | +|---|---|---| +| **Agent definition** | Inline in code (`AsAIAgent(model, instructions)`) | Managed in Foundry platform (`AsAIAgent(agentRecord)`) | +| **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API | +| **Tools** | Defined in code | Configured in the platform | +| **Use case** | Full control over agent behavior | Platform-managed agent with centralized config | diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml new file mode 100644 index 0000000000..9b33646c8a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.manifest.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-foundry-agent +displayName: "Hosted Foundry Agent" + +description: > + A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, + backed by a Foundry-managed agent definition. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Streaming + - Agent Framework + +template: + name: hosted-foundry-agent + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml new file mode 100644 index 0000000000..74223e72fe --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-foundry-agent +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile new file mode 100644 index 0000000000..1b72fcd93f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedLocalTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile.contributor new file mode 100644 index 0000000000..65f920824a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-local-tools . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-local-tools -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-local-tools +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedLocalTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj new file mode 100644 index 0000000000..8871ea5242 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/HostedLocalTools.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + HostedLocalTools + HostedLocalTools + $(NoWarn); + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs new file mode 100644 index 0000000000..f0e3566fe1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/Program.cs @@ -0,0 +1,164 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Seattle Hotel Agent - A hosted agent with local C# function tools. +// Demonstrates how to define and wire local tools that the LLM can invoke, +// a key advantage of code-based hosted agents over prompt agents. + +using System.ComponentModel; +using System.Globalization; +using System.Text; +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Hotel data ─────────────────────────────────────────────────────────────── + +Hotel[] seattleHotels = +[ + new("Contoso Suites", 189, 4.5, "Downtown"), + new("Fabrikam Residences", 159, 4.2, "Pike Place Market"), + new("Alpine Ski House", 249, 4.7, "Seattle Center"), + new("Margie's Travel Lodge", 219, 4.4, "Waterfront"), + new("Northwind Inn", 139, 4.0, "Capitol Hill"), + new("Relecloud Hotel", 99, 3.8, "University District"), +]; + +// ── Tool: GetAvailableHotels ───────────────────────────────────────────────── + +[Description("Get available hotels in Seattle for the specified dates.")] +string GetAvailableHotels( + [Description("Check-in date in YYYY-MM-DD format")] string checkInDate, + [Description("Check-out date in YYYY-MM-DD format")] string checkOutDate, + [Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500) +{ + if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn)) + { + return "Error parsing check-in date. Please use YYYY-MM-DD format."; + } + + if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut)) + { + return "Error parsing check-out date. Please use YYYY-MM-DD format."; + } + + if (checkOut <= checkIn) + { + return "Error: Check-out date must be after check-in date."; + } + + int nights = (checkOut - checkIn).Days; + List availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList(); + + if (availableHotels.Count == 0) + { + return $"No hotels found in Seattle within your budget of ${maxPrice}/night."; + } + + StringBuilder result = new(); + result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):"); + result.AppendLine(); + + foreach (Hotel hotel in availableHotels) + { + int totalCost = hotel.PricePerNight * nights; + result.AppendLine($"**{hotel.Name}**"); + result.AppendLine($" Location: {hotel.Location}"); + result.AppendLine($" Rating: {hotel.Rating}/5"); + result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})"); + result.AppendLine(); + } + + return result.ToString(); +} + +// ── Create and host the agent ──────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. + + When a user asks about hotels in Seattle: + 1. Ask for their check-in and check-out dates if not provided + 2. Ask about their budget preferences if not mentioned + 3. Use the GetAvailableHotels tool to find available options + 4. Present the results in a friendly, informative way + 5. Offer to help with additional questions about the hotels or Seattle + + Be conversational and helpful. If users ask about things outside of Seattle hotels, + politely let them know you specialize in Seattle hotel recommendations. + """, + name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-local-tools", + description: "Seattle hotel search agent with local function tools", + tools: [AIFunctionFactory.Create(GetAvailableHotels)]); + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +// ── Types ──────────────────────────────────────────────────────────────────── + +internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md new file mode 100644 index 0000000000..8016ff7ae9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/README.md @@ -0,0 +1,113 @@ +# Hosted-LocalTools + +A hosted agent with **local C# function tools** for hotel search. Demonstrates how to define and wire local tools that the LLM can invoke — a key advantage of code-based hosted agents over prompt agents. + +The agent specializes in finding hotels in Seattle, with a `GetAvailableHotels` tool that searches a mock hotel database by dates and budget. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools +AGENT_NAME=hosted-local-tools dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "Find me a hotel in Seattle for Dec 20-25 under $200/night" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Find me a hotel in Seattle for Dec 20-25 under $200/night", "model": "hosted-local-tools"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-local-tools . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-local-tools \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-local-tools +``` + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What hotels are available in Seattle for next weekend?" +``` + +## How local tools work + +The agent has a single tool `GetAvailableHotels` defined as a C# method with `[Description]` attributes. The LLM decides when to call it based on the user's request: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `checkInDate` | string | Check-in date (YYYY-MM-DD) | +| `checkOutDate` | string | Check-out date (YYYY-MM-DD) | +| `maxPrice` | int | Max price per night in USD (default: 500) | + +The tool searches a mock database of 6 Seattle hotels and returns formatted results with name, location, rating, and pricing. + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml new file mode 100644 index 0000000000..a056b51649 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.manifest.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-local-tools +displayName: "Seattle Hotel Agent with Local Tools" + +description: > + A travel assistant agent that helps users find hotels in Seattle. + Demonstrates local C# tool execution — a key advantage of code-based + hosted agents over prompt agents. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Local Tools + - Agent Framework + +template: + name: hosted-local-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml new file mode 100644 index 0000000000..18ecc4a9f7 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-LocalTools/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-local-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile new file mode 100644 index 0000000000..fe7fceb685 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedMcpTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile.contributor new file mode 100644 index 0000000000..51c8c347d8 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Dockerfile.contributor @@ -0,0 +1,18 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-mcp-tools . +# docker run --rm -p 8088:8088 -e AGENT_NAME=mcp-tools -e GITHUB_PAT=$GITHUB_PAT -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-mcp-tools +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedMcpTools.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj new file mode 100644 index 0000000000..359e8a35bb --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/HostedMcpTools.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + HostedMcpTools + HostedMcpTools + $(NoWarn); + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs new file mode 100644 index 0000000000..a027047a1d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/Program.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates a hosted agent with two layers of MCP (Model Context Protocol) tools: +// +// 1. CLIENT-SIDE MCP: The agent connects to the Microsoft Learn MCP server directly via +// McpClient, discovers tools, and handles tool invocations locally within the agent process. +// +// 2. SERVER-SIDE MCP: The agent declares a HostedMcpServerTool for the same MCP server which +// delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API). +// The provider calls the MCP server on behalf of the agent — no local connection needed. +// +// Both patterns use the Microsoft Learn MCP server to illustrate the architectural difference: +// client-side tools are resolved and invoked by the agent, while server-side tools are resolved +// and invoked by the LLM provider. + +#pragma warning disable MEAI001 // HostedMcpServerTool is experimental + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Client-side MCP: Microsoft Learn (local resolution) ────────────────────── +// Connect directly to the MCP server. The agent discovers and invokes tools locally. +Console.WriteLine("Connecting to Microsoft Learn MCP server (client-side)..."); + +await using var learnMcp = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn (client)", +})); + +var clientTools = await learnMcp.ListToolsAsync(); +Console.WriteLine($"Client-side MCP tools: {string.Join(", ", clientTools.Select(t => t.Name))}"); + +// ── Server-side MCP: Microsoft Learn (provider resolution) ─────────────────── +// Declare a HostedMcpServerTool — the LLM provider (Responses API) handles tool +// invocations directly. No local MCP connection needed for this pattern. +AITool serverTool = new HostedMcpServerTool( + serverName: "microsoft_learn_hosted", + serverAddress: "https://learn.microsoft.com/api/mcp") +{ + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire +}; +Console.WriteLine("Server-side MCP tool: microsoft_docs_search (via HostedMcpServerTool)"); + +// ── Combine both tool types into a single agent ────────────────────────────── +// The agent has access to tools from both MCP patterns simultaneously. +List allTools = [.. clientTools.Cast(), serverTool]; + +AIAgent agent = new AIProjectClient(projectEndpoint, credential) + .AsAIAgent( + model: deployment, + instructions: """ + You are a helpful developer assistant with access to Microsoft Learn documentation. + Use the available tools to search and retrieve documentation. + Be concise and provide direct answers with relevant links. + """, + name: "mcp-tools", + description: "Developer assistant with dual-layer MCP tools (client-side and server-side)", + tools: allTools); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +// In Development, also map the OpenAI-compatible route that AIProjectClient uses. +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md new file mode 100644 index 0000000000..3773d9760d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/README.md @@ -0,0 +1,83 @@ +# Hosted-McpTools + +A hosted agent demonstrating **two layers of MCP (Model Context Protocol) tool integration**: + +1. **Client-side MCP (Microsoft Learn)** — The agent connects directly to the Microsoft Learn MCP server via `McpClient`, discovers tools, and handles tool invocations locally within the agent process. + +2. **Server-side MCP (Microsoft Learn)** — The agent declares a `HostedMcpServerTool` which delegates tool discovery and invocation to the LLM provider (Azure OpenAI Responses API). The provider calls the MCP server on behalf of the agent with no local connection needed. + +## How the two MCP patterns differ + +| | Client-side MCP | Server-side MCP | +|---|---|---| +| **Connection** | Agent connects to MCP server directly | LLM provider connects to MCP server | +| **Tool invocation** | Handled by the agent process | Handled by the Responses API | +| **Auth** | Agent manages credentials | Provider manages credentials | +| **Use case** | Custom/private MCP servers, fine-grained control | Public MCP servers, simpler setup | +| **Example** | Microsoft Learn (`McpClient` + `HttpClientTransport`) | Microsoft Learn (`HostedMcpServerTool`) | + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools +dotnet run +``` + +### Test it + +Using the Azure Developer CLI: + +```bash +# Uses GitHub MCP (client-side) +azd ai agent invoke --local "Search for the agent-framework repository on GitHub" + +# Uses Microsoft Learn MCP (server-side) +azd ai agent invoke --local "How do I create an Azure storage account using az cli?" +``` + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build and run + +```bash +docker build -f Dockerfile.contributor -t hosted-mcp-tools . + +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=mcp-tools \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-mcp-tools +``` + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml new file mode 100644 index 0000000000..d5952940b0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: mcp-tools +displayName: "MCP Tools Agent" + +description: > + A developer assistant demonstrating dual-layer MCP integration: + client-side GitHub MCP tools handled by the agent and server-side + Microsoft Learn MCP tools delegated to the LLM provider. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Agent Framework + - MCP + - Model Context Protocol + +template: + name: mcp-tools + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml new file mode 100644 index 0000000000..34beb3e2c9 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-McpTools/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: mcp-tools +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile new file mode 100644 index 0000000000..062d0f4f7e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedTextRag.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile.contributor new file mode 100644 index 0000000000..9a90c74335 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-text-rag . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-text-rag -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-text-rag +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedTextRag.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj new file mode 100644 index 0000000000..ed24c32eea --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/HostedTextRag.csproj @@ -0,0 +1,34 @@ + + + + net10.0 + enable + enable + false + HostedTextRag + HostedTextRag + $(NoWarn); + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs new file mode 100644 index 0000000000..33edb58901 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/Program.cs @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) +// capabilities to a hosted agent. The provider runs a search against an external knowledge base +// before each model invocation and injects the results into the model context. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +TextSearchProviderOptions textSearchOptions = new() +{ + SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, + RecentMessageMemoryLimit = 6, +}; + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent(new ChatClientAgentOptions + { + Name = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-text-rag", + ChatOptions = new ChatOptions + { + ModelId = deploymentName, + Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", + }, + AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)] + }); + +// Host the agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +// ── Mock search function ───────────────────────────────────────────────────── +// In production, replace this with a real search provider (e.g., Azure AI Search). + +static Task> MockSearchAsync(string query, CancellationToken cancellationToken) +{ + List results = []; + + if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "Contoso Outdoors Return Policy", + SourceLink = "https://contoso.com/policies/returns", + Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." + }); + } + + if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "Contoso Outdoors Shipping Guide", + SourceLink = "https://contoso.com/help/shipping", + Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." + }); + } + + if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase)) + { + results.Add(new() + { + SourceName = "TrailRunner Tent Care Instructions", + SourceLink = "https://contoso.com/manuals/trailrunner-tent", + Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." + }); + } + + return Task.FromResult>(results); +} + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable. +/// This should NOT be used in production — tokens expire (~1 hour) and cannot be refreshed. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(GetAccessToken()); + + private static AccessToken GetAccessToken() + { + var token = Environment.GetEnvironmentVariable(EnvironmentVariable); + if (string.IsNullOrEmpty(token) || token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md new file mode 100644 index 0000000000..5e4e5140c0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/README.md @@ -0,0 +1,116 @@ +# Hosted-TextRag + +A hosted agent with **Retrieval Augmented Generation (RAG)** capabilities using `TextSearchProvider`. The agent grounds its answers in product documentation by running a search before each model invocation, then citing the source in its response. + +This sample demonstrates how to add knowledge grounding to a hosted agent without requiring an external search index — using a mock search function that can be replaced with Azure AI Search or any other provider. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN= +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +This project uses `ProjectReference` to build against the local Agent Framework source. + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag +AGENT_NAME=hosted-text-rag dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What is your return policy?" +azd ai agent invoke --local "How long does shipping take?" +azd ai agent invoke --local "How do I clean my tent?" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What is your return policy?", "model": "hosted-text-rag"}' +``` + +## Running with Docker + +Since this project uses `ProjectReference`, use `Dockerfile.contributor` which takes a pre-published output. + +### 1. Publish for the container runtime (Linux Alpine) + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-text-rag . +``` + +### 3. Run the container + +Generate a bearer token on your host and pass it to the container: + +```bash +# Generate token (expires in ~1 hour) +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +# Run with token +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-text-rag \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-text-rag +``` + +### 4. Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "What is your return policy?" +``` + +## How RAG works in this sample + +The `TextSearchProvider` runs a mock search **before each model invocation**: + +| User query contains | Search result injected | +|---|---| +| "return" or "refund" | Contoso Outdoors Return Policy | +| "shipping" | Contoso Outdoors Shipping Guide | +| "tent" or "fabric" | TrailRunner Tent Care Instructions | + +The model receives the search results as additional context and cites the source in its response. In production, replace `MockSearchAsync` with a call to Azure AI Search or your preferred search provider. + +## NuGet package users + +If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml new file mode 100644 index 0000000000..1459925136 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-text-rag +displayName: "Hosted Text RAG Agent" + +description: > + A support specialist agent for Contoso Outdoors with RAG capabilities. + Uses TextSearchProvider to ground answers in product documentation + before each model invocation. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - RAG + - Text Search + - Agent Framework + +template: + name: hosted-text-rag + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml new file mode 100644 index 0000000000..c8d6928e2e --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-TextRag/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-text-rag +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj new file mode 100644 index 0000000000..458518c67b --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + enable + enable + false + HostedToolbox + HostedToolbox + $(NoWarn); + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs new file mode 100644 index 0000000000..4327562ee2 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/Program.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools. +// +// Demonstrates how to register one or more Foundry toolsets so the agent can +// call tools provided by the Foundry platform's managed MCP proxy. +// +// Required environment variables: +// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint +// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o) +// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL +// (injected automatically by Foundry platform at runtime) +// +// Optional: +// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset) +// FOUNDRY_AGENT_NAME - Client name reported to MCP server +// FOUNDRY_AGENT_VERSION - Client version reported to MCP server +// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; +string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// ── Create agent ───────────────────────────────────────────────────────────── + +AIAgent agent = new AIProjectClient(new Uri(endpoint), credential) + .AsAIAgent( + model: deploymentName, + instructions: """ + You are a helpful assistant with access to tools provided by the Foundry Toolset. + Use the available tools to answer user questions. + If a tool is not available for a request, let the user know clearly. + """, + name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent", + description: "Hosted agent backed by Foundry Toolset MCP tools"); + +// ── Build the host ──────────────────────────────────────────────────────────── + +var builder = WebApplication.CreateBuilder(args); + +// Register the agent and response handler +builder.Services.AddFoundryResponses(agent); + +// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available. +// The toolset name must match a toolset registered in your Foundry project. +// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry +// infrastructure), startup succeeds without error and no toolbox tools are loaded. +builder.Services.AddFoundryToolboxes(toolboxName); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +// ── DevTemporaryTokenCredential ─────────────────────────────────────────────── + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.MaxValue); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example new file mode 100644 index 0000000000..bfb3c97208 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/.env.example @@ -0,0 +1,5 @@ +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile new file mode 100644 index 0000000000..14b356ad98 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor new file mode 100644 index 0000000000..4cc047c8bc --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Dockerfile.contributor @@ -0,0 +1,19 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source, +# which means a standard multi-stage Docker build cannot resolve dependencies outside +# this folder. Instead, pre-publish the app targeting the container runtime and copy +# the output into the container: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-workflow-handoff . +# docker run --rm -p 8088:8088 -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-workflow-handoff +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowHandoff.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj new file mode 100644 index 0000000000..d6b68609fb --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/HostedWorkflowHandoff.csproj @@ -0,0 +1,42 @@ + + + + Exe + net10.0 + enable + enable + HostedWorkflowHandoff + HostedWorkflowHandoff + false + $(NoWarn);NU1605;MAAIW001 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Pages.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Pages.cs new file mode 100644 index 0000000000..916b0fdf17 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Pages.cs @@ -0,0 +1,470 @@ +// Copyright (c) Microsoft. All rights reserved. + +/// +/// Static HTML pages served by the sample application. +/// +internal static class Pages +{ + // ═══════════════════════════════════════════════════════════════════════ + // Homepage + // ═══════════════════════════════════════════════════════════════════════ + + internal const string Home = """ + + + + + Foundry Responses Hosting — Demos + + + +
+

🚀 Foundry Responses Hosting

+

+ Agent-framework agents hosted via the Azure AI Responses Server SDK.
+ Each demo registers a different agent and serves it through POST /responses. +

+ +
+ All demos share the same /responses endpoint. + The model field in the request selects which agent handles it. +
+
+ + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Tool Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ToolDemo = """ + + + + + Tool Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔧 Tool Demo

+

Agent with local tools (time, weather) + Microsoft Learn MCP (docs search)

+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // Workflow Demo + // ═══════════════════════════════════════════════════════════════════════ + + internal const string WorkflowDemo = """ + + + + + Workflow Demo — Foundry Responses Hosting + + + +
+ ← Back to demos +

🔀 Workflow Demo — Agent Handoffs

+

A triage agent routes your question to a specialist (Code Expert or Creative Writer)

+
+
👤 User → 🔀 Triage → 💻 Code Expert / ✍️ Creative Writer
+
+
+ + + + +
+
+
+ + +
+
+
+ + + + +"""; + + // ═══════════════════════════════════════════════════════════════════════ + // SSE Validator Script (shared by all demo pages) + // ═══════════════════════════════════════════════════════════════════════ + + internal const string ValidationScript = """ +// SseValidator - inline SSE stream validation for Foundry Responses demos +// Captures events during streaming and validates against the API behaviour contract. +(function() { + const style = document.createElement('style'); + style.textContent = ` + .sse-val { margin: .4rem 0 .6rem; padding: .3rem .5rem; font-size: .75rem; color: #aaa; border-top: 1px dashed #e8e8e8; } + .val-ok { color: #7ab88a; } + .val-err { color: #d47272; font-weight: 500; } + .val-issues { margin: .2rem 0; } + .val-issue { color: #c06060; font-size: .72rem; padding: .1rem 0; } + .val-issue b { color: #b04040; } + .val-at { color: #ccc; font-size: .68rem; } + .val-log summary { cursor: pointer; color: #bbb; font-size: .72rem; } + .val-log-items { max-height: 120px; overflow-y: auto; font-size: .7rem; background: #fafafa; + padding: .3rem; border-radius: 3px; margin-top: .15rem; + font-family: 'Cascadia Code', 'Fira Code', monospace; } + .val-i { color: #ccc; display: inline-block; width: 1.8rem; text-align: right; margin-right: .3rem; } + .val-t { color: #8ab4d0; } + `; + document.head.appendChild(style); +})(); + +class SseValidator { + constructor() { this.events = []; } + reset() { this.events = []; } + capture(eventType, data) { this.events.push({ eventType, data }); } + + async validate() { + const resp = await fetch('/api/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: this.events }) + }); + return await resp.json(); + } + + renderElement(result) { + const el = document.createElement('div'); + el.className = 'sse-val'; + const n = result.eventCount; + const ok = result.isValid; + const vs = result.violations || []; + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>'); + + let h = ok + ? `${n} events — all rules passed ✅` + : `${n} events — ${vs.length} violation(s)`; + + if (vs.length) { + h += '
'; + vs.forEach(v => { + h += `
[${esc(v.ruleId)}] ${esc(v.message)} #${v.eventIndex}
`; + }); + h += '
'; + } + + h += `
Event log (${this.events.length})
`; + this.events.forEach((e, i) => { + h += `
${i} ${esc(e.eventType)}
`; + }); + h += '
'; + + el.innerHTML = h; + return el; + } +} +"""; +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs new file mode 100644 index 0000000000..9783aca8f3 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/Program.cs @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates hosting agent-framework agents as Foundry Hosted Agents +// using the Azure AI Responses Server SDK. +// +// Demos: +// / - Homepage listing all demos +// /tool-demo - Agent with local tools + remote MCP tools +// /workflow-demo - Triage workflow routing to specialist agents +// +// Prerequisites: +// - Azure OpenAI resource with a deployed model +// +// Environment variables: +// - AZURE_OPENAI_ENDPOINT - your Azure OpenAI endpoint +// - AZURE_OPENAI_DEPLOYMENT - the model deployment name (default: "gpt-4o") + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +var builder = WebApplication.CreateBuilder(args); + +// --------------------------------------------------------------------------- +// 1. Create the shared Azure OpenAI chat client +// --------------------------------------------------------------------------- +var endpoint = new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.")); +var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o"; + +var azureClient = new AzureOpenAIClient(endpoint, new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential())); +IChatClient chatClient = azureClient.GetResponsesClient().AsIChatClient(deployment); + +// --------------------------------------------------------------------------- +// 2. DEMO 1: Tool Agent — local tools + Microsoft Learn MCP +// --------------------------------------------------------------------------- +Console.WriteLine("Connecting to Microsoft Learn MCP server..."); +McpClient mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = new Uri("https://learn.microsoft.com/api/mcp"), + Name = "Microsoft Learn MCP", +})); +var mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +builder.AddAIAgent( + name: "tool-agent", + instructions: """ + You are a helpful assistant hosted as a Foundry Hosted Agent. + You have access to several tools - use them proactively: + - GetCurrentTime: Returns the current date/time in any timezone. + - GetWeather: Returns weather conditions for any location. + - Microsoft Learn MCP tools: Search and fetch Microsoft documentation. + When a user asks a technical question about Microsoft products, use the + documentation search tools to give accurate, up-to-date answers. + """, + chatClient: chatClient) + .WithAITool(AIFunctionFactory.Create(GetCurrentTime)) + .WithAITool(AIFunctionFactory.Create(GetWeather)) + .WithAITools(mcpTools.Cast().ToArray()); + +// --------------------------------------------------------------------------- +// 3. DEMO 2: Triage Workflow — routes to specialist agents +// --------------------------------------------------------------------------- +ChatClientAgent triageAgent = new( + chatClient, + instructions: """ + You are a triage agent that determines which specialist to hand off to. + Based on the user's question, ALWAYS hand off to one of the available agents. + Do NOT answer the question yourself - just route it. + """, + name: "triage_agent", + description: "Routes messages to the appropriate specialist agent"); + +ChatClientAgent codeExpert = new( + chatClient, + instructions: """ + You are a coding and technology expert. You help with programming questions, + explain technical concepts, debug code, and suggest best practices. + Provide clear, well-structured answers with code examples when appropriate. + """, + name: "code_expert", + description: "Specialist agent for programming and technology questions"); + +ChatClientAgent creativeWriter = new( + chatClient, + instructions: """ + You are a creative writing specialist. You help write stories, poems, + marketing copy, emails, and other creative content. You have a flair + for engaging language and vivid descriptions. + """, + name: "creative_writer", + description: "Specialist agent for creative writing and content tasks"); + +Workflow triageWorkflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triageAgent) + .WithHandoffs(triageAgent, [codeExpert, creativeWriter]) + .WithHandoffs([codeExpert, creativeWriter], triageAgent) + .Build(); + +builder.AddAIAgent("triage-workflow", (_, key) => + triageWorkflow.AsAIAgent(name: key)); + +// Register triage-workflow as the non-keyed default so azd invoke (no model) works +builder.Services.AddSingleton(sp => + sp.GetRequiredKeyedService("triage-workflow")); + +// --------------------------------------------------------------------------- +// 4. Wire up the agent-framework handler and Responses Server SDK +// --------------------------------------------------------------------------- +builder.Services.AddFoundryResponses(); + +var app = builder.Build(); + +// Dispose the MCP client on shutdown +app.Lifetime.ApplicationStopping.Register(() => + mcpClient.DisposeAsync().AsTask().GetAwaiter().GetResult()); + +// --------------------------------------------------------------------------- +// 5. Routes +// --------------------------------------------------------------------------- +app.MapGet("/ready", () => Results.Ok("ready")); +app.MapFoundryResponses(); + +app.MapGet("/", () => Results.Content(Pages.Home, "text/html")); +app.MapGet("/tool-demo", () => Results.Content(Pages.ToolDemo, "text/html")); +app.MapGet("/workflow-demo", () => Results.Content(Pages.WorkflowDemo, "text/html")); +app.MapGet("/js/sse-validator.js", () => Results.Content(Pages.ValidationScript, "application/javascript")); + +// Validation endpoint: accepts captured SSE lines and validates them +app.MapPost("/api/validate", (HostedWorkflowHandoff.CapturedSseStream captured) => +{ + var validator = new HostedWorkflowHandoff.ResponseStreamValidator(); + foreach (var evt in captured.Events) + { + validator.ProcessEvent(evt.EventType, evt.Data); + } + + validator.Complete(); + return Results.Json(validator.GetResult()); +}); + +app.Run(); + +// --------------------------------------------------------------------------- +// Local tool definitions +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Dev-only credential: reads a pre-fetched bearer token from AZURE_BEARER_TOKEN. +// When the value is missing or set to "DefaultAzureCredential", this credential +// throws CredentialUnavailableException so the ChainedTokenCredential falls +// through to DefaultAzureCredential. +// --------------------------------------------------------------------------- + +[Description("Gets the current date and time in the specified timezone.")] +static string GetCurrentTime( + [Description("IANA timezone (e.g. 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC.")] + string timezone = "UTC") +{ + try + { + var tz = TimeZoneInfo.FindSystemTimeZoneById(timezone); + return TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz).ToString("F"); + } + catch + { + return DateTime.UtcNow.ToString("F") + " (UTC - unknown timezone: " + timezone + ")"; + } +} + +[Description("Gets the current weather for a location. Returns temperature, conditions, and humidity.")] +static string GetWeather( + [Description("The city or location (e.g. 'Seattle', 'London, UK').")] + string location) +{ + // Simulated weather - deterministic per location for demo consistency + var rng = new Random(location.ToUpperInvariant().GetHashCode()); + var temp = rng.Next(-5, 35); + string[] conditions = ["sunny", "partly cloudy", "overcast", "rainy", "snowy", "windy", "foggy"]; + var condition = conditions[rng.Next(conditions.Length)]; + return $"Weather in {location}: {temp}C, {condition}. Humidity: {rng.Next(30, 90)}%. Wind: {rng.Next(5, 30)} km/h."; +} + +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md new file mode 100644 index 0000000000..643af74551 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/README.md @@ -0,0 +1,126 @@ +# Hosted-Workflow-Handoff + +A hosted agent server demonstrating two patterns in a single app: + +- **`tool-agent`** — an agent with local tools (time, weather) plus remote Microsoft Learn MCP tools +- **`triage-workflow`** — a handoff workflow that routes conversations to specialist agents (code expert or creative writer) using `AgentWorkflowBuilder` + +Both agents are served over the Responses protocol. The server also exposes interactive web demos at `/tool-demo` and `/workflow-demo`. + +> Unlike the other samples in this folder, this one connects to an **Azure OpenAI** resource directly (not an Azure AI Foundry project endpoint). + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure OpenAI resource with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your values: + +```bash +cp .env.example .env +``` + +Edit `.env`: + +```env +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +``` + +`AZURE_BEARER_TOKEN=DefaultAzureCredential` is a sentinel value that tells the app to skip the bearer token and fall through to `DefaultAzureCredential` (requires `az login`). Set it to a real token only when running in Docker. + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff +dotnet run +``` + +The server starts on `http://localhost:8088`. Open `http://localhost:8088` to see the demo index page. + +### Test it + +Using the Azure Developer CLI (invokes `triage-workflow` — the primary/default agent): + +```bash +azd ai agent invoke --local "Write me a short poem about coding" +``` + +To target a specific agent by name, use curl: + +```bash +# Invoke triage-workflow explicitly +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Write me a haiku about autumn", "model": "triage-workflow"}' +``` + +```bash +# Invoke tool-agent (local tools + MCP) +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "What time is it in Tokyo?", "model": "tool-agent"}' +``` + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-workflow-handoff . +``` + +### 3. Run the container + +```bash +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-workflow-handoff +``` + +### 4. Test it + +```bash +azd ai agent invoke --local "Explain async/await in C#" +``` + +## How the triage workflow works + +``` +User message + │ + ▼ +┌──────────────┐ +│ Triage Agent │ ──routes──▶ ┌─────────────┐ +│ (router) │ │ Code Expert │ +└──────────────┘ └─────────────┘ + ▲ │ + │◀──────────────────────────────┘ + │ + └──routes──▶ ┌─────────────────┐ + │ Creative Writer │ + └─────────────────┘ +``` + +The triage agent receives every message and hands off to the appropriate specialist. Specialists route back to the triage agent after responding, allowing for multi-turn conversations. + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/ResponseStreamValidator.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/ResponseStreamValidator.cs new file mode 100644 index 0000000000..75822608e5 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/ResponseStreamValidator.cs @@ -0,0 +1,601 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HostedWorkflowHandoff; + +/// Captured SSE event for validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseEvent( + [property: JsonPropertyName("eventType")] string EventType, + [property: JsonPropertyName("data")] string Data); + +/// Captured SSE stream sent from the client for server-side validation. +[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1812:AvoidUninstantiatedInternalClasses", Justification = "Instantiated by JSON deserialization")] +internal sealed record CapturedSseStream( + [property: JsonPropertyName("events")] List Events); + +/// +/// Validates an SSE event stream from the Azure AI Responses Server SDK against +/// the API behaviour contract. Feed events sequentially via +/// and call when the stream ends. +/// +internal sealed class ResponseStreamValidator +{ + private readonly List _violations = []; + private int _eventCount; + private int _expectedSequenceNumber; + private StreamState _state = StreamState.Initial; + private string? _responseId; + private readonly HashSet _addedItemIndices = []; + private readonly HashSet _doneItemIndices = []; + private readonly HashSet _addedContentParts = []; // "outputIdx:partIdx" + private readonly HashSet _doneContentParts = []; + private readonly Dictionary _textAccumulators = []; // "outputIdx:contentIdx" → accumulated text + private bool _hasTerminal; + + /// All violations found so far. + internal IReadOnlyList Violations => this._violations; + + /// + /// Processes a single SSE event line pair (event type + JSON data). + /// + /// The SSE event type (e.g. "response.created"). + /// The raw JSON data payload. + internal void ProcessEvent(string eventType, string jsonData) + { + JsonElement data; + try + { + data = JsonDocument.Parse(jsonData).RootElement; + } + catch (JsonException ex) + { + this.Fail("PARSE-01", $"Invalid JSON in event data: {ex.Message}"); + return; + } + + this._eventCount++; + + // ── Sequence number validation ────────────────────────────────── + if (data.TryGetProperty("sequence_number", out var seqProp) && seqProp.ValueKind == JsonValueKind.Number) + { + int seq = seqProp.GetInt32(); + if (seq != this._expectedSequenceNumber) + { + this.Fail("SEQ-01", $"Expected sequence_number {this._expectedSequenceNumber}, got {seq}"); + } + + this._expectedSequenceNumber = seq + 1; + } + else if (this._state != StreamState.Initial || eventType != "error") + { + // Pre-creation error events may not have sequence_number + this.Fail("SEQ-02", $"Missing sequence_number on event '{eventType}'"); + } + + // ── Post-terminal guard ───────────────────────────────────────── + if (this._hasTerminal) + { + this.Fail("TERM-01", $"Event '{eventType}' received after terminal event"); + return; + } + + // ── Dispatch by event type ────────────────────────────────────── + switch (eventType) + { + case "response.created": + this.ValidateResponseCreated(data); + break; + + case "response.queued": + this.ValidateStateTransition(eventType, StreamState.Created, StreamState.Queued); + this.ValidateResponseEnvelope(data, eventType); + break; + + case "response.in_progress": + if (this._state is StreamState.Created or StreamState.Queued) + { + this._state = StreamState.InProgress; + } + else + { + this.Fail("ORDER-02", $"'response.in_progress' received in state {this._state} (expected Created or Queued)"); + } + + this.ValidateResponseEnvelope(data, eventType); + break; + + case "response.output_item.added": + case "output_item.added": + this.ValidateInProgress(eventType); + this.ValidateOutputItemAdded(data); + break; + + case "response.output_item.done": + case "output_item.done": + this.ValidateInProgress(eventType); + this.ValidateOutputItemDone(data); + break; + + case "response.content_part.added": + case "content_part.added": + this.ValidateInProgress(eventType); + this.ValidateContentPartAdded(data); + break; + + case "response.content_part.done": + case "content_part.done": + this.ValidateInProgress(eventType); + this.ValidateContentPartDone(data); + break; + + case "response.output_text.delta": + case "output_text.delta": + this.ValidateInProgress(eventType); + this.ValidateTextDelta(data); + break; + + case "response.output_text.done": + case "output_text.done": + this.ValidateInProgress(eventType); + this.ValidateTextDone(data); + break; + + case "response.function_call_arguments.delta": + case "function_call_arguments.delta": + this.ValidateInProgress(eventType); + break; + + case "response.function_call_arguments.done": + case "function_call_arguments.done": + this.ValidateInProgress(eventType); + break; + + case "response.completed": + this.ValidateTerminal(data, "completed"); + break; + + case "response.failed": + this.ValidateTerminal(data, "failed"); + break; + + case "response.incomplete": + this.ValidateTerminal(data, "incomplete"); + break; + + case "error": + // Pre-creation error — standalone, no response.created precedes it + if (this._state != StreamState.Initial) + { + this.Fail("ERR-01", "'error' event received after response.created — should use response.failed instead"); + } + + this._hasTerminal = true; + break; + + default: + // Unknown events are not violations — the spec may evolve + break; + } + } + + /// + /// Call after the stream ends. Checks that a terminal event was received. + /// + internal void Complete() + { + if (!this._hasTerminal && this._state != StreamState.Initial) + { + this.Fail("TERM-02", "Stream ended without a terminal event (response.completed, response.failed, or response.incomplete)"); + } + + if (this._state == StreamState.Initial && this._eventCount == 0) + { + this.Fail("EMPTY-01", "No events received in the stream"); + } + + // Check for output items that were added but never completed + foreach (int idx in this._addedItemIndices) + { + if (!this._doneItemIndices.Contains(idx)) + { + this.Fail("ITEM-03", $"Output item at index {idx} was added but never received output_item.done"); + } + } + + // Check for content parts that were added but never completed + foreach (string key in this._addedContentParts) + { + if (!this._doneContentParts.Contains(key)) + { + this.Fail("CONTENT-03", $"Content part '{key}' was added but never received content_part.done"); + } + } + } + + /// + /// Returns a summary of all validation results. + /// + internal ValidationResult GetResult() + { + return new ValidationResult( + EventCount: this._eventCount, + IsValid: this._violations.Count == 0, + Violations: [.. this._violations]); + } + + // ═══════════════════════════════════════════════════════════════════════ + // Event-specific validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateResponseCreated(JsonElement data) + { + if (this._state != StreamState.Initial) + { + this.Fail("ORDER-01", $"'response.created' received in state {this._state} (expected Initial — must be first event)"); + return; + } + + this._state = StreamState.Created; + + // Must have a response envelope + if (!data.TryGetProperty("response", out var resp)) + { + this.Fail("FIELD-01", "'response.created' missing 'response' object"); + return; + } + + // Required response fields + this.ValidateRequiredResponseFields(resp, "response.created"); + + // Capture response ID for cross-event checks + if (resp.TryGetProperty("id", out var idProp)) + { + this._responseId = idProp.GetString(); + } + + // Status must be non-terminal + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + if (status is "completed" or "failed" or "incomplete" or "cancelled") + { + this.Fail("STATUS-01", $"'response.created' has terminal status '{status}' — must be 'queued' or 'in_progress'"); + } + } + } + + private void ValidateTerminal(JsonElement data, string expectedKind) + { + if (this._state is StreamState.Initial or StreamState.Created) + { + this.Fail("ORDER-03", $"Terminal event 'response.{expectedKind}' received before 'response.in_progress'"); + } + + this._hasTerminal = true; + this._state = StreamState.Terminal; + + if (!data.TryGetProperty("response", out var resp)) + { + this.Fail("FIELD-01", $"'response.{expectedKind}' missing 'response' object"); + return; + } + + this.ValidateRequiredResponseFields(resp, $"response.{expectedKind}"); + + if (resp.TryGetProperty("status", out var statusProp)) + { + string? status = statusProp.GetString(); + + // completed_at validation (B6) + bool hasCompletedAt = resp.TryGetProperty("completed_at", out var catProp) + && catProp.ValueKind != JsonValueKind.Null; + + if (status == "completed" && !hasCompletedAt) + { + this.Fail("FIELD-02", "'completed_at' must be non-null when status is 'completed'"); + } + + if (status != "completed" && hasCompletedAt) + { + this.Fail("FIELD-03", $"'completed_at' must be null when status is '{status}'"); + } + + // error field validation + bool hasError = resp.TryGetProperty("error", out var errProp) + && errProp.ValueKind != JsonValueKind.Null; + + if (status == "failed" && !hasError) + { + this.Fail("FIELD-04", "'error' must be non-null when status is 'failed'"); + } + + if (status is "completed" or "incomplete" && hasError) + { + this.Fail("FIELD-05", $"'error' must be null when status is '{status}'"); + } + + // error structure validation + if (hasError) + { + this.ValidateErrorObject(errProp, $"response.{expectedKind}"); + } + + // cancelled output must be empty (B11) + if (status == "cancelled" && resp.TryGetProperty("output", out var outputProp) + && outputProp.ValueKind == JsonValueKind.Array && outputProp.GetArrayLength() > 0) + { + this.Fail("CANCEL-01", "Cancelled response must have empty output array (B11)"); + } + + // response ID consistency + if (this._responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != this._responseId) + { + this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'"); + } + } + + // Usage validation (optional, but if present must be structured correctly) + if (resp.TryGetProperty("usage", out var usageProp) && usageProp.ValueKind == JsonValueKind.Object) + { + this.ValidateUsage(usageProp, $"response.{expectedKind}"); + } + } + + private void ValidateOutputItemAdded(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!this._addedItemIndices.Add(index)) + { + this.Fail("ITEM-01", $"Duplicate output_item.added for output_index {index}"); + } + } + else + { + this.Fail("FIELD-06", "output_item.added missing 'output_index' field"); + } + + if (!data.TryGetProperty("item", out _)) + { + this.Fail("FIELD-07", "output_item.added missing 'item' object"); + } + } + + private void ValidateOutputItemDone(JsonElement data) + { + if (data.TryGetProperty("output_index", out var idxProp) && idxProp.ValueKind == JsonValueKind.Number) + { + int index = idxProp.GetInt32(); + if (!this._addedItemIndices.Contains(index)) + { + this.Fail("ITEM-02", $"output_item.done for output_index {index} without preceding output_item.added"); + } + + this._doneItemIndices.Add(index); + } + else + { + this.Fail("FIELD-06", "output_item.done missing 'output_index' field"); + } + } + + private void ValidateContentPartAdded(JsonElement data) + { + string key = GetContentPartKey(data); + if (!this._addedContentParts.Add(key)) + { + this.Fail("CONTENT-01", $"Duplicate content_part.added for {key}"); + } + } + + private void ValidateContentPartDone(JsonElement data) + { + string key = GetContentPartKey(data); + if (!this._addedContentParts.Contains(key)) + { + this.Fail("CONTENT-02", $"content_part.done for {key} without preceding content_part.added"); + } + + this._doneContentParts.Add(key); + } + + private void ValidateTextDelta(JsonElement data) + { + string key = GetTextKey(data); + string delta = data.TryGetProperty("delta", out var deltaProp) + ? deltaProp.GetString() ?? string.Empty + : string.Empty; + + if (!this._textAccumulators.TryGetValue(key, out string? existing)) + { + this._textAccumulators[key] = delta; + } + else + { + this._textAccumulators[key] = existing + delta; + } + } + + private void ValidateTextDone(JsonElement data) + { + string key = GetTextKey(data); + string? finalText = data.TryGetProperty("text", out var textProp) + ? textProp.GetString() + : null; + + if (finalText is null) + { + this.Fail("TEXT-01", $"output_text.done for {key} missing 'text' field"); + return; + } + + if (this._textAccumulators.TryGetValue(key, out string? accumulated) && accumulated != finalText) + { + this.Fail("TEXT-02", $"output_text.done text for {key} does not match accumulated deltas (accumulated {accumulated.Length} chars, done has {finalText.Length} chars)"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Shared field validators + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateRequiredResponseFields(JsonElement resp, string context) + { + if (!HasNonNullString(resp, "id")) + { + this.Fail("FIELD-01", $"{context}: response missing 'id'"); + } + + if (resp.TryGetProperty("object", out var objProp)) + { + if (objProp.GetString() != "response") + { + this.Fail("FIELD-08", $"{context}: response.object must be 'response', got '{objProp.GetString()}'"); + } + } + else + { + this.Fail("FIELD-08", $"{context}: response missing 'object' field"); + } + + if (!resp.TryGetProperty("created_at", out var catProp) || catProp.ValueKind == JsonValueKind.Null) + { + this.Fail("FIELD-09", $"{context}: response missing 'created_at'"); + } + + if (!resp.TryGetProperty("status", out _)) + { + this.Fail("FIELD-10", $"{context}: response missing 'status'"); + } + + if (!resp.TryGetProperty("output", out var outputProp) || outputProp.ValueKind != JsonValueKind.Array) + { + this.Fail("FIELD-11", $"{context}: response missing 'output' array"); + } + } + + private void ValidateErrorObject(JsonElement error, string context) + { + if (!HasNonNullString(error, "code")) + { + this.Fail("ERR-02", $"{context}: error object missing 'code' field"); + } + + if (!HasNonNullString(error, "message")) + { + this.Fail("ERR-03", $"{context}: error object missing 'message' field"); + } + } + + private void ValidateUsage(JsonElement usage, string context) + { + if (!usage.TryGetProperty("input_tokens", out _)) + { + this.Fail("USAGE-01", $"{context}: usage missing 'input_tokens'"); + } + + if (!usage.TryGetProperty("output_tokens", out _)) + { + this.Fail("USAGE-02", $"{context}: usage missing 'output_tokens'"); + } + + if (!usage.TryGetProperty("total_tokens", out _)) + { + this.Fail("USAGE-03", $"{context}: usage missing 'total_tokens'"); + } + } + + private void ValidateResponseEnvelope(JsonElement data, string eventType) + { + if (!data.TryGetProperty("response", out var resp)) + { + this.Fail("FIELD-01", $"'{eventType}' missing 'response' object"); + return; + } + + this.ValidateRequiredResponseFields(resp, eventType); + + // Response ID consistency + if (this._responseId is not null && resp.TryGetProperty("id", out var idProp) + && idProp.GetString() != this._responseId) + { + this.Fail("ID-01", $"Response ID changed: was '{this._responseId}', now '{idProp.GetString()}'"); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // Helpers + // ═══════════════════════════════════════════════════════════════════════ + + private void ValidateInProgress(string eventType) + { + if (this._state != StreamState.InProgress) + { + this.Fail("ORDER-04", $"'{eventType}' received in state {this._state} (expected InProgress)"); + } + } + + private void ValidateStateTransition(string eventType, StreamState expected, StreamState next) + { + if (this._state != expected) + { + this.Fail("ORDER-05", $"'{eventType}' received in state {this._state} (expected {expected})"); + } + else + { + this._state = next; + } + } + + private void Fail(string ruleId, string message) + { + this._violations.Add(new ValidationViolation(ruleId, message, this._eventCount)); + } + + private static bool HasNonNullString(JsonElement obj, string property) + { + return obj.TryGetProperty(property, out var prop) + && prop.ValueKind == JsonValueKind.String + && !string.IsNullOrEmpty(prop.GetString()); + } + + private static string GetContentPartKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int partIdx = data.TryGetProperty("content_index", out var pi) ? pi.GetInt32() : -1; + return $"{outputIdx}:{partIdx}"; + } + + private static string GetTextKey(JsonElement data) + { + int outputIdx = data.TryGetProperty("output_index", out var oi) ? oi.GetInt32() : -1; + int contentIdx = data.TryGetProperty("content_index", out var ci) ? ci.GetInt32() : -1; + return $"{outputIdx}:{contentIdx}"; + } + + private enum StreamState + { + Initial, + Created, + Queued, + InProgress, + Terminal, + } +} + +/// A single validation violation. +/// The rule identifier (e.g. SEQ-01, FIELD-02). +/// Human-readable description of the violation. +/// 1-based index of the event that triggered this violation. +internal sealed record ValidationViolation(string RuleId, string Message, int EventIndex); + +/// Overall validation result. +/// Total number of events processed. +/// True if no violations were found. +/// List of all violations. +internal sealed record ValidationResult(int EventCount, bool IsValid, IReadOnlyList Violations); diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml new file mode 100644 index 0000000000..7909463901 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.manifest.yaml @@ -0,0 +1,30 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: triage-workflow +displayName: "Triage Handoff Workflow Agent" + +description: > + A hosted agent demonstrating two patterns in a single server: a tool-equipped agent + with local tools and remote MCP tools, and a triage workflow that routes conversations + to specialist agents (code expert or creative writer) via handoff orchestration. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Workflows + - Handoff + - Agent Framework + +template: + name: triage-workflow + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml new file mode 100644 index 0000000000..6b192c4eb6 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Handoff/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: triage-workflow +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example new file mode 100644 index 0000000000..b8fe9e8e7a --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/.env.example @@ -0,0 +1,5 @@ +AZURE_AI_PROJECT_ENDPOINT= +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +AZURE_BEARER_TOKEN=DefaultAzureCredential diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile new file mode 100644 index 0000000000..5d6888e222 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile @@ -0,0 +1,17 @@ +# Use the official .NET 10.0 ASP.NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet restore +RUN dotnet publish -c Release -o /app/publish + +# Final stage +FROM base AS final +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowSimple.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile.contributor b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile.contributor new file mode 100644 index 0000000000..17a924237f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Dockerfile.contributor @@ -0,0 +1,18 @@ +# Dockerfile for contributors building from the agent-framework repository source. +# +# This project uses ProjectReference to the local source, which means a standard +# multi-stage Docker build cannot resolve dependencies outside this folder. +# Pre-publish the app targeting the container runtime and copy the output: +# +# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +# docker build -f Dockerfile.contributor -t hosted-workflow-simple . +# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-workflow-simple -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN --env-file .env hosted-workflow-simple +# +# For end-users consuming the NuGet package (not ProjectReference), use the standard +# Dockerfile which performs a full dotnet restore + publish inside the container. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final +WORKDIR /app +COPY out/ . +EXPOSE 8088 +ENV ASPNETCORE_URLS=http://+:8088 +ENTRYPOINT ["dotnet", "HostedWorkflowSimple.dll"] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj new file mode 100644 index 0000000000..75d012413d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/HostedWorkflowSimple.csproj @@ -0,0 +1,36 @@ + + + + net10.0 + enable + enable + false + HostedWorkflowSimple + HostedWorkflowSimple + $(NoWarn); + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs new file mode 100644 index 0000000000..558aef11d4 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/Program.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Translation Chain Workflow Agent — demonstrates how to compose multiple AI agents +// into a sequential workflow pipeline. Three translation agents are connected: +// English → French → Spanish → English, showing how agents can be orchestrated +// as workflow executors in a hosted agent. + +using Azure.AI.Projects; +using Azure.Core; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); +string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o"; + +// Use a chained credential: try a temporary dev token first (for local Docker debugging), +// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production). +TokenCredential credential = new ChainedTokenCredential( + new DevTemporaryTokenCredential(), + new DefaultAzureCredential()); + +// Create a chat client from the Foundry project +IChatClient chatClient = new AIProjectClient(new Uri(endpoint), credential) + .GetProjectOpenAIClient() + .GetChatClient(deploymentName) + .AsIChatClient(); + +// Create translation agents +AIAgent frenchAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to French."); +AIAgent spanishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to Spanish."); +AIAgent englishAgent = chatClient.AsAIAgent("You are a translation assistant that translates the provided text to English."); + +// Build the sequential workflow: French → Spanish → English +AIAgent agent = new WorkflowBuilder(frenchAgent) + .AddEdge(frenchAgent, spanishAgent) + .AddEdge(spanishAgent, englishAgent) + .Build() + .AsAIAgent( + name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-workflows"); + +// Host the workflow agent as a Foundry Hosted Agent using the Responses API. +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddFoundryResponses(agent); + +var app = builder.Build(); +app.MapFoundryResponses(); + +if (app.Environment.IsDevelopment()) +{ + app.MapFoundryResponses("openai/v1"); +} + +app.Run(); + +/// +/// A for local Docker debugging only. +/// Reads a pre-fetched bearer token from the AZURE_BEARER_TOKEN environment variable +/// once at startup. This should NOT be used in production. +/// +/// Generate a token on your host and pass it to the container: +/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) +/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ... +/// +internal sealed class DevTemporaryTokenCredential : TokenCredential +{ + private const string EnvironmentVariable = "AZURE_BEARER_TOKEN"; + private readonly string? _token; + + public DevTemporaryTokenCredential() + { + this._token = Environment.GetEnvironmentVariable(EnvironmentVariable); + } + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => this.GetAccessToken(); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(this.GetAccessToken()); + + private AccessToken GetAccessToken() + { + if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential") + { + throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set."); + } + + return new AccessToken(this._token, DateTimeOffset.UtcNow.AddHours(1)); + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md new file mode 100644 index 0000000000..d91d27445d --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/README.md @@ -0,0 +1,109 @@ +# Hosted-Workflow-Simple + +A hosted agent that demonstrates **multi-agent workflow orchestration**. Three translation agents are composed into a sequential pipeline: English → French → Spanish → English, showing how agents can be chained as workflow executors using `WorkflowBuilder`. + +## Prerequisites + +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) +- An Azure AI Foundry project with a deployed model (e.g., `gpt-4o`) +- Azure CLI logged in (`az login`) + +## Configuration + +Copy the template and fill in your project endpoint: + +```bash +cp .env.example .env +``` + +Edit `.env` and set your Azure AI Foundry project endpoint: + +```env +AZURE_AI_PROJECT_ENDPOINT=https://.services.ai.azure.com/api/projects/ +ASPNETCORE_URLS=http://+:8088 +ASPNETCORE_ENVIRONMENT=Development +AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o +``` + +> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference. + +## Running directly (contributors) + +```bash +cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple +AGENT_NAME=hosted-workflow-simple dotnet run +``` + +The agent will start on `http://localhost:8088`. + +### Test it + +Using the Azure Developer CLI: + +```bash +azd ai agent invoke --local "The quick brown fox jumps over the lazy dog" +``` + +Or with curl: + +```bash +curl -X POST http://localhost:8088/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "The quick brown fox jumps over the lazy dog", "model": "hosted-workflow-simple"}' +``` + +The text will be translated through the chain: English → French → Spanish → English. + +## Running with Docker + +### 1. Publish for the container runtime + +```bash +dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out +``` + +### 2. Build the Docker image + +```bash +docker build -f Dockerfile.contributor -t hosted-workflow-simple . +``` + +### 3. Run the container + +```bash +export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv) + +docker run --rm -p 8088:8088 \ + -e AGENT_NAME=hosted-workflow-simple \ + -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \ + --env-file .env \ + hosted-workflow-simple +``` + +### 4. Test it + +```bash +azd ai agent invoke --local "Hello, how are you today?" +``` + +## How the workflow works + +``` +Input text + │ + ▼ +┌─────────────┐ ┌──────────────┐ ┌──────────────┐ +│ French Agent │ → │ Spanish Agent │ → │ English Agent │ +│ (translate) │ │ (translate) │ │ (translate) │ +└─────────────┘ └──────────────┘ └──────────────┘ + │ + ▼ + Final output + (back in English) +``` + +Each agent in the chain receives the output of the previous agent. The final result demonstrates how meaning is preserved (or subtly shifted) through multiple translation hops. + +## NuGet package users + +Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative. diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml new file mode 100644 index 0000000000..e902b6232f --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.manifest.yaml @@ -0,0 +1,29 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml +name: hosted-workflows +displayName: "Translation Chain Workflow Agent" + +description: > + A workflow agent that performs sequential translation through multiple languages. + Translates text from English to French, then to Spanish, and finally back to English, + demonstrating how AI agents can be composed as workflow executors. + +metadata: + tags: + - AI Agent Hosting + - Azure AI AgentServer + - Responses Protocol + - Workflows + - Agent Framework + +template: + name: hosted-workflows + kind: hosted + protocols: + - protocol: responses + version: 1.0.0 + resources: + cpu: "0.25" + memory: 0.5Gi +parameters: + properties: [] +resources: [] diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml new file mode 100644 index 0000000000..c9c0386cf1 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Workflow-Simple/agent.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml +kind: hosted +name: hosted-workflow-simple +protocols: + - protocol: responses + version: 1.0.0 +resources: + cpu: "0.25" + memory: 0.5Gi diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs new file mode 100644 index 0000000000..5d9c003dfa --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/Program.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.ClientModel.Primitives; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects; +using Azure.Identity; +using DotNetEnv; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Foundry; + +// Load .env file if present (for local development) +Env.TraversePath().Load(); + +Uri agentEndpoint = new(Environment.GetEnvironmentVariable("AGENT_ENDPOINT") + ?? "http://localhost:8088"); + +var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") + ?? throw new InvalidOperationException("AGENT_NAME is not set."); + +// ── Create an agent-framework agent backed by the remote agent endpoint ────── + +var options = new AIProjectClientOptions(); + +if (agentEndpoint.Scheme == "http") +{ + // For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy + // BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right + // before the request hits the wire. + + agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri; + options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport); +} + +var aiProjectClient = new AIProjectClient(agentEndpoint, new AzureCliCredential(), options); +FoundryAgent agent = aiProjectClient.AsAIAgent(new AgentReference(agentName)); + +AgentSession session = await agent.CreateSessionAsync(); + +// ── REPL ────────────────────────────────────────────────────────────────────── + +Console.ForegroundColor = ConsoleColor.Cyan; +Console.WriteLine($""" + ══════════════════════════════════════════════════════════ + Simple Agent Sample + Connected to: {agentEndpoint} + Type a message or 'quit' to exit + ══════════════════════════════════════════════════════════ + """); +Console.ResetColor(); +Console.WriteLine(); + +while (true) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("You> "); + Console.ResetColor(); + + string? input = Console.ReadLine(); + + if (string.IsNullOrWhiteSpace(input)) { continue; } + if (input.Equals("quit", StringComparison.OrdinalIgnoreCase)) { break; } + + try + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.Write("Agent> "); + Console.ResetColor(); + + await foreach (var update in agent.RunStreamingAsync(input, session)) + { + Console.Write(update); + } + + Console.WriteLine(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); +} + +Console.WriteLine("Goodbye!"); + +/// +/// For Local Development Only +/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient +/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check. +/// +internal sealed class HttpSchemeRewritePolicy : PipelinePolicy +{ + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + ProcessNext(message, pipeline, currentIndex); + } + + public override async ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + RewriteScheme(message); + await ProcessNextAsync(message, pipeline, currentIndex).ConfigureAwait(false); + } + + private static void RewriteScheme(PipelineMessage message) + { + var uri = message.Request.Uri!; + if (uri.Scheme == Uri.UriSchemeHttps) + { + message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri; + } + } +} diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj new file mode 100644 index 0000000000..3c739b96d0 --- /dev/null +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent/SimpleAgent.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + false + SimpleAgentClient + simple-agent-client + $(NoWarn);NU1605;OPENAI001 + + + + + + + + + + + + + diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index d5f1c9a88d..e4661f3217 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -8,6 +8,7 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; using OpenAI.Chat; +using AgentCard = A2A.AgentCard; namespace A2AServer; diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj deleted file mode 100644 index a56157fe9d..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj +++ /dev/null @@ -1,69 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - $(NoWarn);MEAI001 - - - false - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile deleted file mode 100644 index 004bd49fa8..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentThreadAndHITL.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs deleted file mode 100644 index fee781d660..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence. -// The agent wraps function tools with ApprovalRequiredAIFunction to require user approval -// before invoking them. Users respond with 'approve' or 'reject' when prompted. - -using System.ComponentModel; -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.AgentServer.AgentFramework.Persistence; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Chat; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - -[Description("Get the weather for a given location.")] -static string GetWeather([Description("The location to get the weather for.")] string location) - => $"The weather in {location} is cloudy with a high of 15°C."; - -// Create the chat client and agent. -// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation. -// User should reply with 'approve' or 'reject' when prompted. -// 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. -#pragma warning disable MEAI001 // Type is for evaluation purposes only -AIAgent agent = new AzureOpenAIClient( - new Uri(endpoint), - new DefaultAzureCredential()) - .GetChatClient(deploymentName) - .AsAIAgent( - instructions: "You are a helpful assistant", - tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))] - ); -#pragma warning restore MEAI001 - -InMemoryAgentThreadRepository threadRepository = new(agent); -await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md deleted file mode 100644 index 465dfacbf0..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates Human-in-the-Loop (HITL) capabilities with thread persistence. The agent wraps function tools with `ApprovalRequiredAIFunction` so that every tool invocation requires explicit user approval before execution. Thread state is maintained across requests using `InMemoryAgentThreadRepository`. - -Key features: -- Requiring human approval before executing function calls -- Persisting conversation threads across multiple requests -- Approving or rejecting tool invocations at runtime - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -Before running this sample, ensure you have: - -1. .NET 10 SDK installed -2. An Azure OpenAI endpoint configured -3. A deployment of a chat model (e.g., gpt-5.4-mini) -4. Azure CLI installed and authenticated (`az login`) - -## Environment Variables - -Set the following environment variables: - -```powershell -# Replace with your Azure OpenAI endpoint -$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" - -# Optional, defaults to gpt-5.4-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -## How It Works - -The sample uses `ApprovalRequiredAIFunction` to wrap standard AI function tools. When the model decides to call a tool, the wrapper intercepts the invocation and returns a HITL approval request to the caller instead of executing the function immediately. - -1. The user sends a message (e.g., "What is the weather in Vancouver?") -2. The model determines a function call is needed and selects the `GetWeather` tool -3. `ApprovalRequiredAIFunction` intercepts the call and returns an approval request containing the function name and arguments -4. The user responds with `approve` or `reject` -5. If approved, the function executes and the model generates a response using the result -6. If rejected, the model generates a response without the function result - -Thread persistence is handled by `InMemoryAgentThreadRepository`, which stores conversation history keyed by `conversation.id`. This means the HITL flow works across multiple HTTP requests as long as each request includes the same `conversation.id`. - -> **Note:** HITL requires a stable `conversation.id` in every request so the agent can correlate the approval response with the original function call. Use the `run-requests.http` file in this directory to test the full approval flow. diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml deleted file mode 100644 index c7e67b3d4e..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/agent.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: AgentThreadAndHITL -displayName: "Weather Assistant Agent" -description: > - A Weather Assistant Agent that provides weather information and forecasts. It - demonstrates how to use Azure AI AgentServer with Human-in-the-Loop (HITL) - capabilities to get human approval for functional calls. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Human-in-the-Loop -template: - kind: hosted - name: AgentThreadAndHITL - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http deleted file mode 100644 index 196a30a542..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/run-requests.http +++ /dev/null @@ -1,70 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### -# HITL (Human-in-the-Loop) Flow -# -# This sample requires a multi-turn conversation to demonstrate the approval flow: -# 1. Send a request that triggers a tool call (e.g., asking about the weather) -# 2. The agent responds with a function_call named "__hosted_agent_adapter_hitl__" -# containing the call_id and the tool details -# 3. Send a follow-up request with a function_call_output to approve or reject -# -# IMPORTANT: You must use the same conversation.id across all requests in a flow, -# and update the call_id from step 2 into step 3. -### - -### Step 1: Send initial request (triggers HITL approval) -# @name initialRequest -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "What is the weather like in Vancouver?", - "stream": false, - "conversation": { - "id": "conv_test0000000000000000000000000000000000000000000000" - } -} - -### Step 2: Approve the function call -# Copy the call_id from the Step 1 response output and replace below. -# The response will contain: "name": "__hosted_agent_adapter_hitl__" with a "call_id" value. -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "function_call_output", - "call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1", - "output": "approve" - } - ], - "stream": false, - "conversation": { - "id": "conv_test0000000000000000000000000000000000000000000000" - } -} - -### Step 3 (alternative): Reject the function call -# Use this instead of Step 2 to deny the tool execution. -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "function_call_output", - "call_id": "REPLACE_WITH_CALL_ID_FROM_STEP_1", - "output": "reject" - } - ], - "stream": false, - "conversation": { - "id": "conv_test0000000000000000000000000000000000000000000000" - } -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj deleted file mode 100644 index 4e46f10c11..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj +++ /dev/null @@ -1,68 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile deleted file mode 100644 index a2590fc112..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentWithHostedMCP.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs deleted file mode 100644 index 4178946604..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool. -// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. -// The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire. - -#pragma warning disable MEAI001 // HostedMcpServerTool, HostedMcpServerToolApprovalMode are experimental -#pragma warning disable OPENAI001 // GetResponsesClient is experimental - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Responses; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - -// Create an MCP tool that can be called without approval. -AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp") -{ - AllowedTools = ["microsoft_docs_search"], - ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire -}; - -// Create an agent with the MCP tool using Azure OpenAI Responses. -// 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()) - .GetResponsesClient(deploymentName) - .AsAIAgent( - instructions: "You answer questions by searching the Microsoft Learn content only.", - name: "MicrosoftLearnAgent", - tools: [mcpTool]); - -await agent.RunAIAgentAsync(); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md deleted file mode 100644 index dc0718dfa7..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates how to use a Hosted Model Context Protocol (MCP) server with an AI agent. -The agent connects to the Microsoft Learn MCP server to search documentation and answer questions using official Microsoft content. - -Key features: -- Configuring MCP tools with automatic approval (no user confirmation required) -- Filtering available tools from an MCP server -- Using Azure OpenAI Responses with MCP tools - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -Before running this sample, ensure you have: - -1. An Azure OpenAI endpoint configured -2. A deployment of a chat model (e.g., gpt-5.4-mini) -3. Azure CLI installed and authenticated - -**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. - -## Environment Variables - -Set the following environment variables: - -```powershell -# Replace with your Azure OpenAI endpoint -$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" - -# Optional, defaults to gpt-5.4-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -## How It Works - -The sample connects to the Microsoft Learn MCP server and uses its documentation search capabilities: - -1. The agent is configured with a HostedMcpServerTool pointing to `https://learn.microsoft.com/api/mcp` -2. Only the `microsoft_docs_search` tool is enabled from the available MCP tools -3. Approval mode is set to `NeverRequire`, allowing automatic tool execution -4. When you ask questions, Azure OpenAI Responses automatically invokes the MCP tool to search documentation -5. The agent returns answers based on the Microsoft Learn content - -In this configuration, the OpenAI Responses service manages tool invocation directly - the Agent Framework does not handle MCP tool calls. diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml deleted file mode 100644 index 7c02acb02a..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/agent.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: AgentWithHostedMCP -displayName: "Microsoft Learn Response Agent with MCP" -description: > - An AI agent that uses Azure OpenAI Responses with a Hosted Model Context Protocol (MCP) server. - The agent answers questions by searching Microsoft Learn documentation using MCP tools. - This demonstrates how MCP tools can be integrated with Azure OpenAI Responses where the service - itself handles tool invocation. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Model Context Protocol - - MCP - - Tool Call Approval -template: - kind: hosted - name: AgentWithHostedMCP - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http deleted file mode 100644 index b7c0b35efd..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/run-requests.http +++ /dev/null @@ -1,32 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple string input - Ask about MCP Tools -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "Please summarize the Azure AI Agent documentation related to MCP Tool calling?" -} - -### Explicit input - Ask about Agent Framework -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "What is the Microsoft Agent Framework?" - } - ] - } - ] -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore deleted file mode 100644 index 2afa2c2601..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/.dockerignore +++ /dev/null @@ -1,24 +0,0 @@ -**/.dockerignore -**/.env -**/.git -**/.gitignore -**/.project -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/*.*proj.user -**/*.dbmdl -**/*.jfm -**/azds.yaml -**/bin -**/charts -**/docker-compose* -**/Dockerfile* -**/node_modules -**/npm-debug.log -**/obj -**/secrets.dev.yaml -**/values.dev.yaml -LICENSE -README.md diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj deleted file mode 100644 index b7970f8c5f..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj +++ /dev/null @@ -1,70 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - true - - - false - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile deleted file mode 100644 index c2461965a4..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentWithLocalTools.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs deleted file mode 100644 index 0da7c57b12..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/Program.cs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Microsoft Foundry. -// Ready for deployment to Foundry Hosted Agent service. - -using System.ClientModel.Primitives; -using System.ComponentModel; -using System.Globalization; -using System.Text; -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; -Console.WriteLine($"Project Endpoint: {endpoint}"); -Console.WriteLine($"Model Deployment: {deploymentName}"); - -Hotel[] seattleHotels = -[ - new Hotel("Contoso Suites", 189, 4.5, "Downtown"), - new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"), - new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"), - new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"), - new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"), - new Hotel("Relecloud Hotel", 99, 3.8, "University District"), -]; - -[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")] -string GetAvailableHotels( - [Description("Check-in date in YYYY-MM-DD format")] string checkInDate, - [Description("Check-out date in YYYY-MM-DD format")] string checkOutDate, - [Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500) -{ - try - { - if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn)) - { - return "Error parsing check-in date. Please use YYYY-MM-DD format."; - } - - if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut)) - { - return "Error parsing check-out date. Please use YYYY-MM-DD format."; - } - - if (checkOut <= checkIn) - { - return "Error: Check-out date must be after check-in date."; - } - - int nights = (checkOut - checkIn).Days; - List availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList(); - - if (availableHotels.Count == 0) - { - return $"No hotels found in Seattle within your budget of ${maxPrice}/night."; - } - - StringBuilder result = new(); - result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):"); - result.AppendLine(); - - foreach (Hotel hotel in availableHotels) - { - int totalCost = hotel.PricePerNight * nights; - result.AppendLine($"**{hotel.Name}**"); - result.AppendLine($" Location: {hotel.Location}"); - result.AppendLine($" Rating: {hotel.Rating}/5"); - result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})"); - result.AppendLine(); - } - - return result.ToString(); - } - catch (Exception ex) - { - return $"Error processing request. Details: {ex.Message}"; - } -} - -// 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. -DefaultAzureCredential credential = new(); -AIProjectClient projectClient = new(new Uri(endpoint), credential); - -ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!); - -if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is null) -{ - throw new InvalidOperationException("Failed to get OpenAI endpoint from project connection."); -} -openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}"); -Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}"); - -IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential) - .GetChatClient(deploymentName) - .AsIChatClient() - .AsBuilder() - .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) - .Build(); - -AIAgent agent = chatClient.AsAIAgent( - name: "SeattleHotelAgent", - instructions: """ - You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. - - When a user asks about hotels in Seattle: - 1. Ask for their check-in and check-out dates if not provided - 2. Ask about their budget preferences if not mentioned - 3. Use the GetAvailableHotels tool to find available options - 4. Present the results in a friendly, informative way - 5. Offer to help with additional questions about the hotels or Seattle - - Be conversational and helpful. If users ask about things outside of Seattle hotels, - politely let them know you specialize in Seattle hotel recommendations. - """, - tools: [AIFunctionFactory.Create(GetAvailableHotels)]) - .AsBuilder() - .UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false) - .Build(); - -Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088"); -await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); - -internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md deleted file mode 100644 index aba51898ec..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates how to build a hosted agent that uses local C# function tools — a key advantage of code-based hosted agents over prompt agents. The agent acts as a Seattle travel assistant with a `GetAvailableHotels` tool that simulates querying a hotel availability API. - -Key features: -- Defining local C# functions as agent tools using `AIFunctionFactory` -- Using `AIProjectClient` to discover the OpenAI connection from the Microsoft Foundry project -- Building a `ChatClientAgent` with custom instructions and tools -- Deploying to the Foundry Hosted Agent service - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -Before running this sample, ensure you have: - -1. .NET 10 SDK installed -2. A Microsoft Foundry Project with a chat model deployed (e.g., gpt-5.4-mini) -3. Azure CLI installed and authenticated (`az login`) - -## Environment Variables - -Set the following environment variables: - -```powershell -# Replace with your Microsoft Foundry project endpoint -$env:AZURE_AI_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project-name" - -# Optional, defaults to gpt-5.4-mini -$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -## How It Works - -1. The agent uses `AIProjectClient` to discover the Azure OpenAI connection from the project endpoint -2. A local C# function `GetAvailableHotels` is registered as a tool using `AIFunctionFactory.Create` -3. When users ask about hotels, the model invokes the local tool to search simulated hotel data -4. The tool filters hotels by price and calculates total costs based on the requested dates -5. Results are returned to the model, which presents them in a conversational format diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml deleted file mode 100644 index 7e75a738ce..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/agent.yaml +++ /dev/null @@ -1,29 +0,0 @@ -name: seattle-hotel-agent -description: > - A travel assistant agent that helps users find hotels in Seattle. - Demonstrates local C# tool execution - a key advantage of code-based - hosted agents over prompt agents. -metadata: - authors: - - Microsoft - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Local Tools - - Travel Assistant - - Hotel Search -template: - name: seattle-hotel-agent - kind: hosted - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_AI_PROJECT_ENDPOINT - value: ${AZURE_AI_PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - kind: model - id: gpt-5.4-mini - name: chat diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http deleted file mode 100644 index 4f2e87e097..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/run-requests.http +++ /dev/null @@ -1,52 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple hotel search - budget under $200 -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night", - "stream": false -} - -### Hotel search with higher budget -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night", - "stream": false -} - -### Ask for recommendations without dates (agent should ask for clarification) -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "What hotels do you recommend in Seattle?", - "stream": false -} - -### Explicit input format -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum" - } - ] - } - ], - "stream": false -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj deleted file mode 100644 index 7789abd315..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj +++ /dev/null @@ -1,68 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile deleted file mode 100644 index 3d944c9883..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentWithTextSearchRag.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs deleted file mode 100644 index 518ce5679f..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/Program.cs +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) -// capabilities to an AI agent. The provider runs a search against an external knowledge base -// before each model invocation and injects the results into the model context. - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; -using OpenAI.Chat; - -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - -TextSearchProviderOptions textSearchOptions = new() -{ - // Run the search prior to every model invocation and keep a short rolling window of conversation context. - SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke, - RecentMessageMemoryLimit = 6, -}; - -// 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(new ChatClientAgentOptions - { - ChatOptions = new ChatOptions - { - Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.", - }, - AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)] - }); - -await agent.RunAIAgentAsync(); - -static Task> MockSearchAsync(string query, CancellationToken cancellationToken) -{ - // The mock search inspects the user's question and returns pre-defined snippets - // that resemble documents stored in an external knowledge source. - List results = []; - - if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase)) - { - results.Add(new() - { - SourceName = "Contoso Outdoors Return Policy", - SourceLink = "https://contoso.com/policies/returns", - Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection." - }); - } - - if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase)) - { - results.Add(new() - { - SourceName = "Contoso Outdoors Shipping Guide", - SourceLink = "https://contoso.com/help/shipping", - Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout." - }); - } - - if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase)) - { - results.Add(new() - { - SourceName = "TrailRunner Tent Care Instructions", - SourceLink = "https://contoso.com/manuals/trailrunner-tent", - Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating." - }); - } - - return Task.FromResult>(results); -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md deleted file mode 100644 index b62d9068ce..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context. - -Key features: -- Configuring TextSearchProvider with custom search behavior -- Running searches before AI invocations to provide relevant context -- Managing conversation memory with a rolling window approach -- Citing source documents in AI responses - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -Before running this sample, ensure you have: - -1. An Azure OpenAI endpoint configured -2. A deployment of a chat model (e.g., gpt-5.4-mini) -3. Azure CLI installed and authenticated - -## Environment Variables - -Set the following environment variables: - -```powershell -# Replace with your Azure OpenAI endpoint -$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/" - -# Optional, defaults to gpt-5.4-mini -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -## How It Works - -The sample uses a mock search function that demonstrates the RAG pattern: - -1. When the user asks a question, the TextSearchProvider intercepts it -2. The search function looks for relevant documents based on the query -3. Retrieved documents are injected into the model's context -4. The AI responds using both its training and the provided context -5. The agent can cite specific source documents in its answers - -The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.). diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml deleted file mode 100644 index 6cdad09e9c..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/agent.yaml +++ /dev/null @@ -1,31 +0,0 @@ -name: AgentWithTextSearchRag -displayName: "Text Search RAG Agent" -description: > - An AI agent that uses TextSearchProvider for retrieval augmented generation (RAG) capabilities. - The agent runs searches against an external knowledge base before each model invocation and - injects the results into the model context. It can answer questions about Contoso Outdoors - policies and products, including return policies, refunds, shipping options, and product care - instructions such as tent maintenance. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Retrieval-Augmented Generation - - RAG -template: - kind: hosted - name: AgentWithTextSearchRag - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http deleted file mode 100644 index 4bfb02d8f8..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/run-requests.http +++ /dev/null @@ -1,30 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple string input -POST {{endpoint}} -Content-Type: application/json -{ - "input": "Hi! I need help understanding the return policy." -} - -### Explicit input -POST {{endpoint}} -Content-Type: application/json -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "How long does standard shipping usually take?" - } - ] - } - ] -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj deleted file mode 100644 index 7789abd315..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj +++ /dev/null @@ -1,68 +0,0 @@ - - - - Exe - net10.0 - - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile deleted file mode 100644 index 86b6c156f3..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "AgentsInWorkflows.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs deleted file mode 100644 index 886e205acf..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/Program.cs +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates how to integrate AI agents into a workflow pipeline. -// Three translation agents are connected sequentially to create a translation chain: -// English → French → Spanish → English, showing how agents can be composed as workflow executors. - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Extensions.AI; - -// Set up the Azure OpenAI client -string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_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. -IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetChatClient(deploymentName) - .AsIChatClient(); - -// Create agents -AIAgent frenchAgent = GetTranslationAgent("French", chatClient); -AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient); -AIAgent englishAgent = GetTranslationAgent("English", chatClient); - -// Build the workflow and turn it into an agent -AIAgent agent = new WorkflowBuilder(frenchAgent) - .AddEdge(frenchAgent, spanishAgent) - .AddEdge(spanishAgent, englishAgent) - .Build() - .AsAIAgent(); - -await agent.RunAIAgentAsync(); - -static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) => - chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}."); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md deleted file mode 100644 index b7a2f9ca53..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# What this sample demonstrates - -This sample demonstrates the use of AI agents as executors within a workflow. - -This workflow uses three translation agents: -1. French Agent - translates input text to French -2. Spanish Agent - translates French text to Spanish -3. English Agent - translates Spanish text back to English - -The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines. - -> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md). - -## Prerequisites - -Before you begin, ensure you have the following prerequisites: - -- .NET 10 SDK or later -- Azure OpenAI service endpoint and deployment configured -- Azure CLI installed and authenticated (for Azure credential authentication) - -**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively). - -Set the following environment variables: - -```powershell -$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint -$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini \ No newline at end of file diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml deleted file mode 100644 index 3c97fa2ac1..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/agent.yaml +++ /dev/null @@ -1,28 +0,0 @@ -name: AgentsInWorkflows -displayName: "Translation Chain Workflow Agent" -description: > - A workflow agent that performs sequential translation through multiple languages. - The agent translates text from English to French, then to Spanish, and finally back - to English, leveraging AI-powered translation capabilities in a pipeline workflow. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Workflows -template: - kind: hosted - name: AgentsInWorkflows - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_OPENAI_ENDPOINT - value: ${AZURE_OPENAI_ENDPOINT} - - name: AZURE_OPENAI_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http deleted file mode 100644 index 5c33700a93..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/run-requests.http +++ /dev/null @@ -1,30 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple string input -POST {{endpoint}} -Content-Type: application/json -{ - "input": "Hello, how are you today?" -} - -### Explicit input -POST {{endpoint}} -Content-Type: application/json -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Hello, how are you today?" - } - ] - } - ] -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile deleted file mode 100644 index fc3d3a1a5b..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "FoundryMultiAgent.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj deleted file mode 100644 index e8c7a434b0..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj +++ /dev/null @@ -1,76 +0,0 @@ - - - Exe - net10.0 - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - PreserveNewest - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs deleted file mode 100644 index cc1e3314f0..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/Program.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents -// using Microsoft Foundry AIProjectClient and the Agent Framework WorkflowBuilder. - -#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Workflows; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; - -Console.WriteLine($"Using Azure AI endpoint: {endpoint}"); -Console.WriteLine($"Using model deployment: {deploymentName}"); - -// 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 aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create Foundry agents -AIAgent writerAgent = await aiProjectClient.CreateAIAgentAsync( - name: "Writer", - model: deploymentName, - instructions: "You are an excellent content writer. You create new content and edit contents based on the feedback."); - -AIAgent reviewerAgent = await aiProjectClient.CreateAIAgentAsync( - name: "Reviewer", - model: deploymentName, - instructions: "You are an excellent content reviewer. Provide actionable feedback to the writer about the provided content. Provide the feedback in the most concise manner possible."); - -try -{ - var workflow = new WorkflowBuilder(writerAgent) - .AddEdge(writerAgent, reviewerAgent) - .Build(); - - Console.WriteLine("Starting Writer-Reviewer Workflow Agent Server on http://localhost:8088"); - await workflow.AsAIAgent().RunAIAgentAsync(); -} -finally -{ - // Cleanup server-side agents - await aiProjectClient.Agents.DeleteAgentAsync(writerAgent.Name); - await aiProjectClient.Agents.DeleteAgentAsync(reviewerAgent.Name); -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md deleted file mode 100644 index 390df95e20..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/README.md +++ /dev/null @@ -1,168 +0,0 @@ -**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). - -Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. - -Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. - -Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. - -# What this sample demonstrates - -This sample demonstrates a **key advantage of code-based hosted agents**: - -- **Multi-agent workflows** - Orchestrate multiple agents working together - -Code-based agents can execute **any C# code** you write. This sample includes a Writer-Reviewer workflow where two agents collaborate: a Writer creates content and a Reviewer provides feedback. - -The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/) and can be deployed to Microsoft Foundry. - -## How It Works - -### Multi-Agent Workflow - -In [Program.cs](Program.cs), the sample creates two agents using `AIProjectClient.CreateAIAgentAsync()` from the [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) package: - -- **Writer** - An agent that creates and edits content based on feedback -- **Reviewer** - An agent that provides actionable feedback on the content - -The `WorkflowBuilder` from the [Microsoft.Agents.AI.Workflows](https://www.nuget.org/packages/Microsoft.Agents.AI.Workflows/) package connects these agents in a sequential flow: - -1. The Writer receives the initial request and generates content -2. The Reviewer evaluates the content and provides feedback -3. Both agent responses are output to the user - -### Agent Hosting - -The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/), -which provisions a REST API endpoint compatible with the OpenAI Responses protocol. - -## Running the Agent Locally - -### Prerequisites - -Before running this sample, ensure you have: - -1. **Microsoft Foundry Project** - - Project created. - - Chat model deployed (e.g., `gpt-5.4-mini`) - - Note your project endpoint URL and model deployment name - > **Note**: You can right-click the project in the Microsoft Foundry VS Code extension and select `Copy Project Endpoint URL` to get the endpoint. - -2. **Azure CLI** - - Installed and authenticated - - Run `az login` and verify with `az account show` - - Your identity needs the **Azure AI Developer** role on the Foundry resource (for `agents/write` data action required by `CreateAIAgentAsync`) - -3. **.NET 10.0 SDK or later** - - Verify your version: `dotnet --version` - - Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download) - -### Environment Variables - -Set the following environment variables: - -**PowerShell:** - -```powershell -# Replace with your actual values -$env:AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -**Bash:** - -```bash -export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -### Running the Sample - -To run the agent, execute the following command in your terminal: - -```bash -dotnet restore -dotnet build -dotnet run -``` - -This will start the hosted agent locally on `http://localhost:8088/`. - -### Interacting with the Agent - -**VS Code:** - -1. Open the Visual Studio Code Command Palette and execute the `Microsoft Foundry: Open Container Agent Playground Locally` command. -2. Execute the following commands to start the containerized hosted agent. - ```bash - dotnet restore - dotnet build - dotnet run - ``` -3. Submit a request to the agent through the playground interface. For example, you may enter a prompt such as: "Create a slogan for a new electric SUV that is affordable and fun to drive." -4. Review the agent's response in the playground interface. - -> **Note**: Open the local playground before starting the container agent to ensure the visualization functions correctly. - -**PowerShell (Windows):** - -```powershell -$body = @{ - input = "Create a slogan for a new electric SUV that is affordable and fun to drive" - stream = $false -} | ConvertTo-Json - -Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" -``` - -**Bash/curl (Linux/macOS):** - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ - -d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive","stream":false}' -``` - -You can also use the `run-requests.http` file in this directory with the VS Code REST Client extension. - -The Writer agent will generate content based on your prompt, and the Reviewer agent will provide feedback on the output. - -## Deploying the Agent to Microsoft Foundry - -**Preparation (required)** - -Please check the environment_variables section in [agent.yaml](agent.yaml) and ensure the variables there are set in your target Microsoft Foundry Project. - -To deploy the hosted agent: - -1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command. - -2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs. - -3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground. - -**What the deploy flow does for you:** - -- Creates or obtains an Azure Container Registry for the target project. -- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`). -- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime). -- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it. - -## MSI Configuration in the Azure Portal - -This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role. - -To configure the Managed Identity: - -1. In the Azure Portal, open the Foundry Project. -2. Select "Access control (IAM)" from the left-hand menu. -3. Click "Add" and choose "Add role assignment". -4. In the role selection, search for and select "Azure AI User", then click "Next". -5. For "Assign access to", choose "Managed identity". -6. Click "Select members", locate the managed identity associated with your Foundry Project (you can search by the project name), then click "Select". -7. Click "Review + assign" to complete the assignment. -8. Allow a few minutes for the role assignment to propagate before running the application. - -## Additional Resources - -- [Microsoft Agents Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) -- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/) diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml deleted file mode 100644 index 79d848fa5a..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/agent.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml - -name: FoundryMultiAgent -displayName: "Foundry Multi-Agent Workflow" -description: > - A multi-agent workflow featuring a Writer and Reviewer that collaborate - to create and refine content using Microsoft Foundry PersistentAgentsClient. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Multi-Agent Workflow - - Writer-Reviewer - - Content Creation -template: - kind: hosted - name: FoundryMultiAgent - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_AI_PROJECT_ENDPOINT - value: ${AZURE_AI_PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json deleted file mode 100644 index eae0c9ec3f..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/appsettings.Development.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "AZURE_AI_PROJECT_ENDPOINT": "https://.services.ai.azure.com/api/projects/", - "MODEL_DEPLOYMENT_NAME": "gpt-5.4-mini" -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http deleted file mode 100644 index 2fcdb2499e..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundryMultiAgent/run-requests.http +++ /dev/null @@ -1,34 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple string input - Content creation request -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "Create a slogan for a new electric SUV that is affordable and fun to drive", - "stream": false -} - -### Explicit input format -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "Write a short product description for a smart water bottle that tracks hydration" - } - ] - } - ], - "stream": false -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile deleted file mode 100644 index 0d1141cc69..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# Build the application -FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build -WORKDIR /src - -# Copy files from the current directory on the host to the working directory in the container -COPY . . - -RUN dotnet restore -RUN dotnet build -c Release --no-restore -RUN dotnet publish -c Release --no-build -o /app -f net10.0 - -# Run the application -FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final -WORKDIR /app - -# Copy everything needed to run the app from the "build" stage. -COPY --from=build /app . - -EXPOSE 8088 -ENTRYPOINT ["dotnet", "FoundrySingleAgent.dll"] diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj deleted file mode 100644 index 70df458d90..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj +++ /dev/null @@ -1,67 +0,0 @@ - - - Exe - net10.0 - enable - enable - - - false - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs deleted file mode 100644 index c09a0a4a82..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/Program.cs +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle. -// Uses Microsoft Agent Framework with Microsoft Foundry. -// Ready for deployment to Foundry Hosted Agent service. - -#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features - -using System.ComponentModel; -using System.Globalization; -using System.Text; - -using Azure.AI.AgentServer.AgentFramework.Extensions; -using Azure.AI.Projects; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Extensions.AI; - -// Get configuration from environment variables -var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; -Console.WriteLine($"Project Endpoint: {endpoint}"); -Console.WriteLine($"Model Deployment: {deploymentName}"); -// Simulated hotel data for Seattle -var seattleHotels = new[] -{ - new Hotel("Contoso Suites", 189, 4.5, "Downtown"), - new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"), - new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"), - new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"), - new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"), - new Hotel("Relecloud Hotel", 99, 3.8, "University District"), -}; - -[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")] -string GetAvailableHotels( - [Description("Check-in date in YYYY-MM-DD format")] string checkInDate, - [Description("Check-out date in YYYY-MM-DD format")] string checkOutDate, - [Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500) -{ - try - { - // Parse dates - if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn)) - { - return "Error parsing check-in date. Please use YYYY-MM-DD format."; - } - - if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut)) - { - return "Error parsing check-out date. Please use YYYY-MM-DD format."; - } - - // Validate dates - if (checkOut <= checkIn) - { - return "Error: Check-out date must be after check-in date."; - } - - var nights = (checkOut - checkIn).Days; - - // Filter hotels by price - var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList(); - - if (availableHotels.Count == 0) - { - return $"No hotels found in Seattle within your budget of ${maxPrice}/night."; - } - - // Build response - var result = new StringBuilder(); - result - .AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):") - .AppendLine(); - - foreach (var hotel in availableHotels) - { - var totalCost = hotel.PricePerNight * nights; - result - .AppendLine($"**{hotel.Name}**") - .AppendLine($" Location: {hotel.Location}") - .AppendLine($" Rating: {hotel.Rating}/5") - .AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})") - .AppendLine(); - } - - return result.ToString(); - } - catch (Exception ex) - { - return $"Error processing request. Details: {ex.Message}"; - } -} - -// 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 aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); - -// Create Foundry agent with hotel search tool -AIAgent agent = await aiProjectClient.CreateAIAgentAsync( - name: "SeattleHotelAgent", - model: deploymentName, - instructions: """ - You are a helpful travel assistant specializing in finding hotels in Seattle, Washington. - - When a user asks about hotels in Seattle: - 1. Ask for their check-in and check-out dates if not provided - 2. Ask about their budget preferences if not mentioned - 3. Use the GetAvailableHotels tool to find available options - 4. Present the results in a friendly, informative way - 5. Offer to help with additional questions about the hotels or Seattle - - Be conversational and helpful. If users ask about things outside of Seattle hotels, - politely let them know you specialize in Seattle hotel recommendations. - """, - tools: [AIFunctionFactory.Create(GetAvailableHotels)]); - -try -{ - Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088"); - await agent.RunAIAgentAsync(telemetrySourceName: "Agents"); -} -finally -{ - // Cleanup server-side agent - await aiProjectClient.Agents.DeleteAgentAsync(agent.Name); -} - -// Hotel record for simulated data -internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md deleted file mode 100644 index 43c5a6cb69..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/README.md +++ /dev/null @@ -1,167 +0,0 @@ -**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md). - -Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct. - -Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates. - -Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output. - -# What this sample demonstrates - -This sample demonstrates a **key advantage of code-based hosted agents**: - -- **Local C# tool execution** - Run custom C# methods as agent tools - -Code-based agents can execute **any C# code** you write. This sample includes a Seattle Hotel Agent with a `GetAvailableHotels` tool that searches for available hotels based on check-in/check-out dates and budget preferences. - -The agent is hosted using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme) and can be deployed to Microsoft Foundry. - -## How It Works - -### Local Tools Integration - -In [Program.cs](Program.cs), the agent uses `AIProjectClient.CreateAIAgentAsync()` from the [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) package to create a Foundry agent with a local C# method (`GetAvailableHotels`) that simulates a hotel availability API. This demonstrates how code-based agents can execute custom server-side logic that prompt agents cannot access. - -The tool accepts: - -- **checkInDate** - Check-in date in YYYY-MM-DD format -- **checkOutDate** - Check-out date in YYYY-MM-DD format -- **maxPrice** - Maximum price per night in USD (optional, defaults to $500) - -### Agent Hosting - -The agent is hosted using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme), -which provisions a REST API endpoint compatible with the OpenAI Responses protocol. - -## Running the Agent Locally - -### Prerequisites - -Before running this sample, ensure you have: - -1. **Microsoft Foundry Project** - - Project created. - - Chat model deployed (e.g., `gpt-5.4-mini`) - - Note your project endpoint URL and model deployment name - -2. **Azure CLI** - - Installed and authenticated - - Run `az login` and verify with `az account show` - - Your identity needs the **Azure AI Developer** role on the Foundry resource (for `agents/write` data action required by `CreateAIAgentAsync`) - -3. **.NET 10.0 SDK or later** - - Verify your version: `dotnet --version` - - Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download) - -### Environment Variables - -Set the following environment variables (matching `agent.yaml`): - -- `AZURE_AI_PROJECT_ENDPOINT` - Your Microsoft Foundry project endpoint URL (required) -- `MODEL_DEPLOYMENT_NAME` - The deployment name for your chat model (defaults to `gpt-5.4-mini`) - -**PowerShell:** - -```powershell -# Replace with your actual values -$env:AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -$env:MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -**Bash:** - -```bash -export AZURE_AI_PROJECT_ENDPOINT="https://.services.ai.azure.com/api/projects/" -export MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" -``` - -### Running the Sample - -To run the agent, execute the following command in your terminal: - -```bash -dotnet restore -dotnet build -dotnet run -``` - -This will start the hosted agent locally on `http://localhost:8088/`. - -### Interacting with the Agent - -**VS Code:** - -1. Open the Visual Studio Code Command Palette and execute the `Microsoft Foundry: Open Container Agent Playground Locally` command. -2. Execute the following commands to start the containerized hosted agent. - - ```bash - dotnet restore - dotnet build - dotnet run - ``` - -3. Submit a request to the agent through the playground interface. For example, you may enter a prompt such as: "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night." -4. The agent will use the GetAvailableHotels tool to search for available hotels matching your criteria. - -> **Note**: Open the local playground before starting the container agent to ensure the visualization functions correctly. - -**PowerShell (Windows):** - -```powershell -$body = @{ - input = "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under `$200 per night" - stream = $false -} | ConvertTo-Json - -Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json" -``` - -**Bash/curl (Linux/macOS):** - -```bash -curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \ - -d '{"input": "Find me hotels in Seattle for March 20-23, 2025 under $200 per night","stream":false}' -``` - -You can also use the `run-requests.http` file in this directory with the VS Code REST Client extension. - -The agent will use the `GetAvailableHotels` tool to search for available hotels matching your criteria. - -## Deploying the Agent to Microsoft Foundry - -**Preparation (required)** - -Please check the environment_variables section in [agent.yaml](agent.yaml) and ensure the variables there are set in your target Microsoft Foundry Project. - -To deploy the hosted agent: - -1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command. -2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs. -3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground. - -**What the deploy flow does for you:** - -- Creates or obtains an Azure Container Registry for the target project. -- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`). -- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime). -- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it. - -## MSI Configuration in the Azure Portal - -This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role. - -To configure the Managed Identity: - -1. In the Azure Portal, open the Foundry Project. -2. Select "Access control (IAM)" from the left-hand menu. -3. Click "Add" and choose "Add role assignment". -4. In the role selection, search for and select "Azure AI User", then click "Next". -5. For "Assign access to", choose "Managed identity". -6. Click "Select members", locate the managed identity associated with your Foundry Project (you can search by the project name), then click "Select". -7. Click "Review + assign" to complete the assignment. -8. Allow a few minutes for the role assignment to propagate before running the application. - -## Additional Resources - -- [Microsoft Agents Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview) -- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/) diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml deleted file mode 100644 index eacb3ec2c0..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/agent.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml - -name: FoundrySingleAgent -displayName: "Foundry Single Agent with Local Tools" -description: > - A travel assistant agent that helps users find hotels in Seattle. - Demonstrates local C# tool execution - a key advantage of code-based - hosted agents over prompt agents. -metadata: - authors: - - Microsoft Agent Framework Team - tags: - - Azure AI AgentServer - - Microsoft Agent Framework - - Local Tools - - Travel Assistant - - Hotel Search -template: - kind: hosted - name: FoundrySingleAgent - protocols: - - protocol: responses - version: v1 - environment_variables: - - name: AZURE_AI_PROJECT_ENDPOINT - value: ${AZURE_AI_PROJECT_ENDPOINT} - - name: MODEL_DEPLOYMENT_NAME - value: gpt-5.4-mini -resources: - - name: "gpt-5.4-mini" - kind: model - id: gpt-5.4-mini \ No newline at end of file diff --git a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http b/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http deleted file mode 100644 index 4f2e87e097..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/FoundrySingleAgent/run-requests.http +++ /dev/null @@ -1,52 +0,0 @@ -@host = http://localhost:8088 -@endpoint = {{host}}/responses - -### Health Check -GET {{host}}/readiness - -### Simple hotel search - budget under $200 -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "I need a hotel in Seattle from 2025-03-15 to 2025-03-18, budget under $200 per night", - "stream": false -} - -### Hotel search with higher budget -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "Find me hotels in Seattle for March 20-23, 2025 under $250 per night", - "stream": false -} - -### Ask for recommendations without dates (agent should ask for clarification) -POST {{endpoint}} -Content-Type: application/json - -{ - "input": "What hotels do you recommend in Seattle?", - "stream": false -} - -### Explicit input format -POST {{endpoint}} -Content-Type: application/json - -{ - "input": [ - { - "type": "message", - "role": "user", - "content": [ - { - "type": "input_text", - "text": "I'm looking for a hotel in Seattle from 2025-04-01 to 2025-04-05, my budget is $150 per night maximum" - } - ] - } - ], - "stream": false -} diff --git a/dotnet/samples/05-end-to-end/HostedAgents/README.md b/dotnet/samples/05-end-to-end/HostedAgents/README.md deleted file mode 100644 index a2b603cc34..0000000000 --- a/dotnet/samples/05-end-to-end/HostedAgents/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Hosted Agent Samples - -These samples demonstrate how to build and host AI agents using the [Azure AI AgentServer SDK](https://learn.microsoft.com/en-us/dotnet/api/overview/azure/ai.agentserver.agentframework-readme). Each sample can be run locally and deployed to Microsoft Foundry as a hosted agent. - -## Samples - -| Sample | Description | -|--------|-------------| -| [`AgentWithLocalTools`](./AgentWithLocalTools/) | Local C# function tool execution (Seattle hotel search) | -| [`AgentThreadAndHITL`](./AgentThreadAndHITL/) | Human-in-the-loop with `ApprovalRequiredAIFunction` and thread persistence | -| [`AgentWithHostedMCP`](./AgentWithHostedMCP/) | Hosted MCP server tool (Microsoft Learn search) | -| [`AgentWithTextSearchRag`](./AgentWithTextSearchRag/) | RAG with `TextSearchProvider` (Contoso Outdoors) | -| [`AgentsInWorkflows`](./AgentsInWorkflows/) | Sequential workflow pipeline (translation chain) | -| [`FoundryMultiAgent`](./FoundryMultiAgent/) | Multi-agent Writer-Reviewer workflow using `AIProjectClient.CreateAIAgentAsync()` from [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) | -| [`FoundrySingleAgent`](./FoundrySingleAgent/) | Single agent with local C# tool execution (hotel search) using `AIProjectClient.CreateAIAgentAsync()` from [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) | - -## Common Prerequisites - -Before running any sample, ensure you have: - -1. **.NET 10 SDK** or later — [Download](https://dotnet.microsoft.com/download/dotnet/10.0) -2. **Azure CLI** installed — [Install guide](https://learn.microsoft.com/cli/azure/install-azure-cli) -3. **Azure OpenAI** or **Microsoft Foundry project** with a chat model deployed (e.g., `gpt-5.4-mini`) - -### Authenticate with Azure CLI - -All samples use `DefaultAzureCredential` for authentication, which automatically probes multiple credential sources (environment variables, managed identity, Azure CLI, etc.). For local development, the simplest approach is to authenticate via Azure CLI: - -```powershell -az login -az account show # Verify the correct subscription -``` - -### Common Environment Variables - -Most samples require one or more of these environment variables: - -| Variable | Used By | Description | -|----------|---------|-------------| -| `AZURE_OPENAI_ENDPOINT` | Most samples | Your Azure OpenAI resource endpoint URL | -| `AZURE_OPENAI_DEPLOYMENT_NAME` | Most samples | Chat model deployment name (defaults to `gpt-5.4-mini`) | -| `AZURE_AI_PROJECT_ENDPOINT` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Microsoft Foundry project endpoint | -| `MODEL_DEPLOYMENT_NAME` | AgentWithLocalTools, FoundryMultiAgent, FoundrySingleAgent | Chat model deployment name (defaults to `gpt-5.4-mini`) | - -See each sample's README for the specific variables required. - -## Microsoft Foundry Setup (for samples that use Foundry) - -Some samples (`AgentWithLocalTools`, `FoundrySingleAgent`, `FoundryMultiAgent`) connect to a Microsoft Foundry project. If you're using these samples, you'll need additional setup. - -### Azure AI Developer Role - -Some Foundry operations require the **Azure AI Developer** role on the Cognitive Services resource. Even if you created the project, you may not have this role by default. - -```powershell -az role assignment create ` - --role "Azure AI Developer" ` - --assignee "your-email@microsoft.com" ` - --scope "/subscriptions/{subscription-id}/resourceGroups/{resource-group}/providers/Microsoft.CognitiveServices/accounts/{account-name}" -``` - -> **Note**: You need **Owner** or **User Access Administrator** permissions on the resource to assign roles. If you don't have this, you may need to request JIT (Just-In-Time) elevated access via [Azure PIM](https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadmigratedresource). - -For more details on permissions, see [Microsoft Foundry Permissions](https://aka.ms/FoundryPermissions). - -## Running a Sample - -Each sample runs as a standalone hosted agent on `http://localhost:8088/`: - -```powershell -cd -dotnet run -``` - -### Interacting with the Agent - -Each sample includes a `run-requests.http` file for testing with the [VS Code REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) extension, or you can use PowerShell: - -```powershell -$body = @{ input = "Your question here" } | ConvertTo-Json -Invoke-RestMethod -Uri "http://localhost:8088/responses" -Method Post -Body $body -ContentType "application/json" -``` - -## Deploying to Microsoft Foundry - -Each sample includes a `Dockerfile` and `agent.yaml` for deployment. To deploy your agent to Microsoft Foundry, follow the [hosted agents deployment guide](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/hosted-agents). - -## Troubleshooting - -### `PermissionDenied` — lacks `agents/write` data action - -Assign the **Azure AI Developer** role to your user. See [Azure AI Developer Role](#azure-ai-developer-role) above. - -### Multi-framework error when running `dotnet run` - -If you see "Your project targets multiple frameworks", specify the framework: - -```powershell -dotnet run --framework net10.0 -``` diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs new file mode 100644 index 0000000000..1c5a57eb49 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -0,0 +1,379 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// A implementation that bridges the Azure AI Responses Server SDK +/// with agent-framework instances, enabling agent-framework agents and workflows +/// to be hosted as Azure Foundry Hosted Agents. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public class AgentFrameworkResponseHandler : ResponseHandler +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly FoundryToolboxService? _toolboxService; + + /// + /// Initializes a new instance of the class + /// that resolves agents from keyed DI services. + /// + /// The service provider for resolving agents. + /// The logger instance. + /// Optional Foundry Toolbox service providing MCP tools. + public AgentFrameworkResponseHandler( + IServiceProvider serviceProvider, + ILogger logger, + FoundryToolboxService? toolboxService = null) + { + ArgumentNullException.ThrowIfNull(serviceProvider); + ArgumentNullException.ThrowIfNull(logger); + + this._serviceProvider = serviceProvider; + this._logger = logger; + this._toolboxService = toolboxService; + } + + /// + public override async IAsyncEnumerable CreateAsync( + CreateResponse request, + ResponseContext context, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // 1. Resolve agent + var agent = this.ResolveAgent(request); + var sessionStore = this.ResolveSessionStore(request); + + // 2. Load or create a new session from the interaction + var sessionConversationId = request.GetConversationId(); + + var chatClientAgent = agent.GetService(); + + AgentSession? session = !string.IsNullOrWhiteSpace(sessionConversationId) + ? await sessionStore.GetSessionAsync(agent, sessionConversationId, cancellationToken).ConfigureAwait(false) + : chatClientAgent is not null + ? await chatClientAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false) + : await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); + + // 3. Create the SDK event stream builder + var stream = new ResponseEventStream(context, request); + + // 3. Emit lifecycle events + yield return stream.EmitCreated(); + yield return stream.EmitInProgress(); + + // 4. Convert input: history + current input → ChatMessage[] + var messages = new List(); + + // Load conversation history if available + var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false); + if (history.Count > 0) + { + messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history)); + } + + // Load and convert current input items + var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + if (inputItems.Count > 0) + { + messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems)); + } + else + { + // Fall back to raw request input + messages.AddRange(InputConverter.ConvertInputToMessages(request)); + } + + // 5. Build chat options + var chatOptions = InputConverter.ConvertToChatOptions(request); + chatOptions.Instructions = request.Instructions; + + // Inject Foundry Toolbox tools when the toolbox service is available. + // + // Two sources are considered: + // 1. Pre-registered toolboxes (via AddFoundryToolboxes) — always appended. + // 2. Per-request markers embedded in request.Tools (HostedMcpToolboxAITool) + // whose ServerAddress scheme is "foundry-toolbox://". Strict mode rejects + // unknown names; otherwise a lazy MCP client is opened and cached. + // + // Each toolbox's tools are only appended once per request, even if it appears + // in both the pre-registered list and the per-request markers. + if (this._toolboxService is not null) + { + List? toolsToAdd = null; + + if (this._toolboxService.Tools.Count > 0) + { + toolsToAdd = [.. this._toolboxService.Tools]; + } + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + string? resolutionError = null; + + foreach (var (name, version) in markers) + { + if (!seen.Add(name)) + { + continue; + } + + IReadOnlyList? toolboxTools = null; + try + { + toolboxTools = await this._toolboxService + .GetToolboxToolsAsync(name, version, cancellationToken) + .ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning( + ex, + "Foundry toolbox '{ToolboxName}' could not be resolved for response {ResponseId}.", + name, + context.ResponseId); + } + + resolutionError = ex.Message; + break; + } + + toolsToAdd ??= []; + foreach (var t in toolboxTools) + { + if (!toolsToAdd.Contains(t)) + { + toolsToAdd.Add(t); + } + } + } + + if (resolutionError is not null) + { + yield return stream.EmitFailed(ResponseErrorCode.ServerError, resolutionError); + yield break; + } + + if (toolsToAdd?.Count > 0) + { + chatOptions.Tools = [.. chatOptions.Tools ?? [], .. toolsToAdd]; + } + } + + var options = new ChatClientAgentRunOptions(chatOptions); + + // 6. Set up consent context for -32006 OAuth consent interception. + // We create a linked CTS so the consent-aware tool wrapper can cancel the agent + // run mid-loop when a -32006 error is returned by the proxy. The RequestConsentState + // is a shared mutable object that flows via AsyncLocal to the tool wrapper. + using var consentCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var consentState = new RequestConsentState { CancellationSource = consentCts }; + McpConsentContext.Current.Value = consentState; + + // 7. Run the agent and convert output + // NOTE: C# forbids 'yield return' inside a try block that has a catch clause, + // and inside catch blocks. We use a flag to defer the yield to outside the try/catch. + bool emittedTerminal = false; + var enumerator = OutputConverter.ConvertUpdatesToEventsAsync( + agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token), + stream, + cancellationToken).GetAsyncEnumerator(cancellationToken); + try + { + while (true) + { + bool shutdownDetected = false; + McpConsentInfo? consentInfo = null; + ResponseStreamEvent? failedEvent = null; + ResponseStreamEvent? evt = null; + try + { + if (!await enumerator.MoveNextAsync().ConfigureAwait(false)) + { + break; + } + + evt = enumerator.Current; + } + catch (OperationCanceledException) when (!emittedTerminal && consentState.Pending is not null) + { + // -32006 consent error: the tool wrapper cancelled consentCts and stored consent info. + consentInfo = consentState.Pending; + } + catch (OperationCanceledException) when (context.IsShutdownRequested && !emittedTerminal) + { + shutdownDetected = true; + } + catch (Exception ex) when (ex is not OperationCanceledException && !emittedTerminal) + { + // Catch agent execution errors and emit a proper failed event + // with the real error message instead of letting the SDK emit + // a generic "An internal server error occurred." + if (this._logger.IsEnabled(LogLevel.Error)) + { + this._logger.LogError(ex, "Agent execution failed for response {ResponseId}.", context.ResponseId); + } + + failedEvent = stream.EmitFailed( + ResponseErrorCode.ServerError, + ex.Message); + } + + if (consentInfo is not null) + { + // Emit mcp_approval_request output item + incomplete for the consent URL. + foreach (var approvalEvent in stream.OutputItemMcpApprovalRequest( + consentInfo.ToolboxName, + consentInfo.ToolName, + consentInfo.ConsentUrl)) + { + yield return approvalEvent; + } + + yield return stream.EmitIncomplete(reason: null); + yield break; + } + + if (failedEvent is not null) + { + yield return failedEvent; + yield break; + } + + if (shutdownDetected) + { + // Server is shutting down — emit incomplete so clients can resume + this._logger.LogInformation("Shutdown detected, emitting incomplete response."); + yield return stream.EmitIncomplete(); + yield break; + } + + // yield is in the outer try (finally-only) — allowed by C# + yield return evt!; + + if (evt is ResponseCompletedEvent or ResponseFailedEvent or ResponseIncompleteEvent) + { + emittedTerminal = true; + } + } + } + finally + { + await enumerator.DisposeAsync().ConfigureAwait(false); + + // Persist session after streaming completes (successful or not) + if (session is not null && !string.IsNullOrWhiteSpace(sessionConversationId)) + { + await sessionStore.SaveSessionAsync(agent, sessionConversationId, session, cancellationToken).ConfigureAwait(false); + } + } + } + + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AIAgent ResolveAgent(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var agent = this._serviceProvider.GetKeyedService(agentName); + if (agent is not null) + { + return FoundryHostingExtensions.ApplyOpenTelemetry(agent); + } + + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("Agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + } + + // Try non-keyed default + var defaultAgent = this._serviceProvider.GetService(); + if (defaultAgent is not null) + { + return FoundryHostingExtensions.ApplyOpenTelemetry(defaultAgent); + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AIAgent is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AIAgent."; + + throw new InvalidOperationException(errorMessage); + } + + /// + /// Resolves an from the request. + /// Tries agent.name first, then falls back to metadata["entity_id"]. + /// If neither is present, attempts to resolve a default (non-keyed) . + /// + private AgentSessionStore ResolveSessionStore(CreateResponse request) + { + var agentName = GetAgentName(request); + + if (!string.IsNullOrEmpty(agentName)) + { + var sessionStore = this._serviceProvider.GetKeyedService(agentName); + if (sessionStore is not null) + { + return sessionStore; + } + + if (this._logger.IsEnabled(LogLevel.Warning)) + { + this._logger.LogWarning("SessionStore for agent '{AgentName}' not found in keyed services. Attempting default resolution.", agentName); + } + } + + // Try non-keyed default + var defaultSessionStore = this._serviceProvider.GetService(); + if (defaultSessionStore is not null) + { + return defaultSessionStore; + } + + var errorMessage = string.IsNullOrEmpty(agentName) + ? "No agent name specified in the request (via agent.name or metadata[\"entity_id\"]) and no default AgentSessionStore is registered." + : $"Agent '{agentName}' not found. Ensure it is registered via AddAIAgent(\"{agentName}\", ...) or as a default AgentSessionStore."; + + throw new InvalidOperationException(errorMessage); + } + + private static string? GetAgentName(CreateResponse request) + { + // Try agent.name from AgentReference + var agentName = request.AgentReference?.Name; + + // Fall back to "model" field (OpenAI clients send the agent name as the model) + if (string.IsNullOrEmpty(agentName)) + { + agentName = request.Model; + } + + // Fall back to metadata["entity_id"] + if (string.IsNullOrEmpty(agentName) && request.Metadata?.AdditionalProperties is not null) + { + request.Metadata.AdditionalProperties.TryGetValue("entity_id", out agentName); + } + + return agentName; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs new file mode 100644 index 0000000000..fe63dcfca7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentSessionStore.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Defines the contract for storing and retrieving agent conversation sessions. +/// +/// +/// Implementations of this interface enable persistent storage of conversation sessions, +/// allowing conversations to be resumed across HTTP requests, application restarts, +/// or different service instances in hosted scenarios. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public abstract class AgentSessionStore +{ + /// + /// Saves a serialized agent session to persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session. + /// The session to save. + /// The to monitor for cancellation requests. + /// A task that represents the asynchronous save operation. + public abstract ValueTask SaveSessionAsync( + AIAgent agent, + string conversationId, + AgentSession session, + CancellationToken cancellationToken = default); + + /// + /// Retrieves a serialized agent session from persistent storage. + /// + /// The agent that owns this session. + /// The unique identifier for the conversation/session to retrieve. + /// The to monitor for cancellation requests. + /// + /// A task that represents the asynchronous retrieval operation. + /// The task result contains the session, or a new session if not found. + /// + public abstract ValueTask GetSessionAsync( + AIAgent agent, + string conversationId, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs new file mode 100644 index 0000000000..5f3ec0ed9b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using ModelContextProtocol; +using ModelContextProtocol.Client; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// An wrapper around that intercepts +/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and +/// propagates it back to via +/// . +/// +/// +/// +/// When the proxy returns -32006, the consent URL is stored in +/// and the per-request is cancelled. This causes +/// to stop the tool loop (it guards +/// exceptions with when (!ct.IsCancellationRequested)) and surfaces an +/// to the handler. The handler then emits the +/// mcp_approval_request output item and marks the response as incomplete. +/// +/// +internal sealed class ConsentAwareMcpClientAIFunction : AIFunction +{ + private readonly McpClientTool _inner; + private readonly string _toolboxName; + + internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName) + { + this._inner = inner; + this._toolboxName = toolboxName; + } + + public override string Name => this._inner.Name; + + public override string Description => this._inner.Description; + + public override JsonElement JsonSchema => this._inner.JsonSchema; + + public override JsonElement? ReturnJsonSchema => this._inner.ReturnJsonSchema; + + public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions; + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + try + { + return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); + } + catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006) + { + var state = McpConsentContext.Current.Value; + if (state is not null) + { + state.Pending = new McpConsentInfo(this._toolboxName, this._inner.Name, ex.Message); + state.CancellationSource?.Cancel(); + } + + cancellationToken.ThrowIfCancellationRequested(); + throw; // fallback if the CT wasn't cancelled for some reason + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs new file mode 100644 index 0000000000..1e69d09189 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Extension methods for that require Azure.AI.Projects 2.1.0-beta.1+ +/// types (e.g. , ). +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryAIToolExtensions +{ + /// + /// Creates an marker from a retrieved + /// from AIProjectClient. Uses and + /// . + /// + /// The toolbox record. + /// An marker backed by . + public static AITool CreateHostedMcpToolbox(ToolboxRecord toolbox) + { + if (toolbox is null) + { + throw new ArgumentNullException(nameof(toolbox)); + } + + return new HostedMcpToolboxAITool(toolbox.Name, toolbox.DefaultVersion); + } + + /// + /// Creates an marker from a specific + /// retrieved from AIProjectClient. Uses and + /// . + /// + /// The toolbox version. + /// An marker backed by . + public static AITool CreateHostedMcpToolbox(ToolboxVersion toolboxVersion) + { + if (toolboxVersion is null) + { + throw new ArgumentNullException(nameof(toolboxVersion)); + } + + return new HostedMcpToolboxAITool(toolboxVersion.Name, toolboxVersion.Version); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs new file mode 100644 index 0000000000..d345297276 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxBearerTokenHandler.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// An that: +/// +/// Acquires a fresh Azure bearer token (scope: https://cognitiveservices.azure.com/.default) per request. +/// Injects the Foundry-Features header from FOUNDRY_AGENT_TOOLSET_FEATURES when non-empty. +/// Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7). +/// +/// +internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler +{ + private const int MaxRetries = 3; + private static readonly TokenRequestContext s_tokenContext = + new(["https://cognitiveservices.azure.com/.default"]); + + private readonly TokenCredential _credential; + private readonly string? _featuresHeaderValue; + + internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue) + { + this._credential = credential; + this._featuresHeaderValue = featuresHeaderValue; + } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await this._credential + .GetTokenAsync(s_tokenContext, cancellationToken) + .ConfigureAwait(false); + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + + if (!string.IsNullOrEmpty(this._featuresHeaderValue)) + { + request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue); + } + + // MaxRetries is the total number of attempts (not additional retries after the first). + for (int attempt = 0; attempt < MaxRetries; attempt++) + { + // Clone the request for retries (the original request cannot be sent twice) + HttpRequestMessage requestToSend = attempt == 0 + ? request + : await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false); + + var response = await base.SendAsync(requestToSend, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode is not (HttpStatusCode.TooManyRequests + or HttpStatusCode.InternalServerError + or HttpStatusCode.BadGateway + or HttpStatusCode.ServiceUnavailable)) + { + return response; + } + + // Last attempt exhausted — return the error response as-is. + if (attempt == MaxRetries - 1) + { + return response; + } + + response.Dispose(); + + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), cancellationToken) + .ConfigureAwait(false); + } + + // Unreachable when MaxRetries > 0, but satisfies the compiler. + throw new InvalidOperationException("Retry loop completed without returning a response."); + } + + private static async Task CloneRequestAsync( + HttpRequestMessage original, + CancellationToken cancellationToken) + { + var clone = new HttpRequestMessage(original.Method, original.RequestUri); + + foreach (var header in original.Headers) + { + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (original.Content is not null) + { + var contentBytes = await original.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + clone.Content = new ByteArrayContent(contentBytes); + + foreach (var header in original.Content.Headers) + { + clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + + return clone; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs new file mode 100644 index 0000000000..78430f40bf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxOptions.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Options for Foundry Toolbox MCP integration. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryToolboxOptions +{ + /// + /// Gets the list of toolbox names to connect to at startup. + /// Each name corresponds to a toolbox registered in the Foundry project. + /// The platform proxy URL is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion} + /// + public IList ToolboxNames { get; } = []; + + /// + /// Gets or sets the Toolsets API version to use when constructing proxy URLs. + /// + public string ApiVersion { get; set; } = "2025-05-01-preview"; + + /// + /// Gets or sets a value indicating whether per-request toolbox markers (referenced via + /// foundry-toolbox:// on the wire) are restricted to toolboxes pre-registered + /// via . When (the default), a request + /// that references an unknown toolbox is rejected. When , the + /// server lazily opens an MCP connection for the referenced toolbox on first use and + /// caches it. + /// + public bool StrictMode { get; set; } = true; + + /// + /// For testing only: overrides FOUNDRY_AGENT_TOOLSET_ENDPOINT. + /// Not part of the public API. + /// + internal string? EndpointOverride { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs new file mode 100644 index 0000000000..7a8bc71e02 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolboxService.cs @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Shared.DiagnosticIds; +using ModelContextProtocol.Client; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// An that eagerly connects to the Foundry Toolboxes MCP proxy at +/// container startup, discovers tools via tools/list, and caches them so they can be +/// injected into every by . +/// +/// +/// +/// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent the service starts without error and +/// no tools are registered, keeping the container healthy per spec §2. +/// +/// +/// Startup eagerly connects to every name in . +/// Beyond those, per-request toolbox markers (see ) are +/// resolved at request time through . Unknown toolboxes are +/// rejected when is and +/// lazily connected otherwise. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable +{ + private readonly FoundryToolboxOptions _options; + private readonly TokenCredential _credential; + private readonly ILogger _logger; + + private readonly Dictionary _toolboxes = new(StringComparer.OrdinalIgnoreCase); + private readonly SemaphoreSlim _lazyOpenLock = new(1, 1); + + private string? _resolvedEndpoint; + private string? _featuresHeader; + private string _agentName = "hosted-agent"; + private string _agentVersion = "1.0.0"; + + /// + /// Gets the cached list of instances discovered from all + /// pre-registered toolboxes. Always non-null after startup. + /// + public IReadOnlyList Tools { get; private set; } = []; + + /// + /// Initializes a new instance of . + /// + public FoundryToolboxService( + IOptions options, + TokenCredential credential, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(credential); + + this._options = options.Value; + this._credential = credential; + this._logger = logger ?? NullLogger.Instance; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + this._resolvedEndpoint = this._options.EndpointOverride + ?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + + if (string.IsNullOrEmpty(this._resolvedEndpoint)) + { + this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled."); + this.Tools = []; + return; + } + + this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES"); + this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent"; + this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0"; + + if (this._options.ToolboxNames.Count == 0) + { + this._logger.LogInformation("No pre-registered toolbox names configured."); + this.Tools = []; + return; + } + + var allTools = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var toolboxName in this._options.ToolboxNames) + { + if (!seen.Add(toolboxName)) + { + continue; + } + + try + { + var cached = await this.OpenToolboxAsync(toolboxName, version: null, cancellationToken).ConfigureAwait(false); + this._toolboxes[toolboxName] = cached; + allTools.AddRange(cached.Tools); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + if (this._logger.IsEnabled(LogLevel.Error)) + { + this._logger.LogError( + ex, + "Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.", + toolboxName); + } + } + } + + this.Tools = allTools; + } + + /// + /// Resolves the tools for a per-request toolbox marker. Returns cached tools when the + /// toolbox has already been opened; otherwise honors + /// to either reject or lazily open it. + /// + /// The Foundry toolbox name from the marker. + /// + /// Optional pinned version. Currently reserved for future use — version-specific routing is + /// handled server-side by the Foundry proxy. This parameter is accepted for forward compatibility + /// but does not affect the proxy URL used to connect to the toolbox. + /// + /// The request cancellation token. + /// + /// Thrown when the toolbox is not pre-registered and + /// is , or when the toolbox endpoint is not configured. + /// + public async ValueTask> GetToolboxToolsAsync( + string toolboxName, + string? version, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolboxName); + + if (this._toolboxes.TryGetValue(toolboxName, out var cached)) + { + return cached.Tools; + } + + if (this._options.StrictMode) + { + throw new InvalidOperationException( + $"Toolbox '{toolboxName}' is not pre-registered via AddFoundryToolboxes(...). " + + $"Either register it at startup or set {nameof(FoundryToolboxOptions.StrictMode)}=false to allow lazy resolution."); + } + + if (string.IsNullOrEmpty(this._resolvedEndpoint)) + { + throw new InvalidOperationException( + $"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set."); + } + + await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + // Double-check after acquiring the lock to avoid duplicate opens under concurrency. + if (this._toolboxes.TryGetValue(toolboxName, out cached)) + { + return cached.Tools; + } + + cached = await this.OpenToolboxAsync(toolboxName, version, cancellationToken).ConfigureAwait(false); + this._toolboxes[toolboxName] = cached; + return cached.Tools; + } + finally + { + this._lazyOpenLock.Release(); + } + } + + private async Task OpenToolboxAsync( + string toolboxName, + string? version, + CancellationToken cancellationToken) + { + var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}"; + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation("Connecting to toolbox '{ToolboxName}' at {ProxyUrl}.", toolboxName, proxyUrl); + } + + var handler = new FoundryToolboxBearerTokenHandler(this._credential, this._featuresHeader) + { + InnerHandler = new HttpClientHandler() + }; + + var httpClient = new HttpClient(handler); + + var transportOptions = new HttpClientTransportOptions + { + Endpoint = new Uri(proxyUrl), + Name = toolboxName, + }; + + var transport = new HttpClientTransport(transportOptions, httpClient); + + var clientOptions = new McpClientOptions + { + ClientInfo = new() + { + Name = this._agentName, + Version = this._agentVersion + } + }; + + var client = await McpClient.CreateAsync( + transport, + clientOptions, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var mcpTools = await client.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + + if (this._logger.IsEnabled(LogLevel.Information)) + { + this._logger.LogInformation( + "Toolbox '{ToolboxName}': discovered {ToolCount} tool(s).", + toolboxName, + mcpTools.Count); + } + + var wrapped = new List(mcpTools.Count); + foreach (var tool in mcpTools) + { + wrapped.Add(new ConsentAwareMcpClientAIFunction(tool, toolboxName)); + } + + _ = version; // reserved for future version-specific routing; currently handled server-side by the proxy. + + return new CachedToolbox(client, httpClient, wrapped); + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + public async ValueTask DisposeAsync() + { + foreach (var cached in this._toolboxes.Values) + { + await cached.Client.DisposeAsync().ConfigureAwait(false); + cached.HttpClient.Dispose(); + } + + this._toolboxes.Clear(); + this._lazyOpenLock.Dispose(); + } + + private sealed record CachedToolbox(McpClient Client, HttpClient HttpClient, IReadOnlyList Tools); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs new file mode 100644 index 0000000000..78d4638635 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InMemoryAgentSessionStore.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Provides an in-memory implementation of for development and testing scenarios. +/// +/// +/// +/// This implementation stores sessions in memory using a concurrent dictionary and is suitable for: +/// +/// Single-instance development scenarios +/// Testing and prototyping +/// Scenarios where session persistence across restarts is not required +/// +/// +/// +/// Warning: All stored sessions will be lost when the application restarts. +/// For production use with multiple instances or persistence across restarts, use a durable storage implementation +/// such as Redis, SQL Server, or Azure Cosmos DB. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class InMemoryAgentSessionStore : AgentSessionStore +{ + private readonly ConcurrentDictionary _sessions = new(); + + /// + public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + this._sessions[key] = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default) + { + var key = GetKey(conversationId, agent.Id); + JsonElement? sessionContent = this._sessions.TryGetValue(key, out var existingSession) ? existingSession : null; + + return sessionContent switch + { + null => await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false), + _ => await agent.DeserializeSessionAsync(sessionContent.Value, cancellationToken: cancellationToken).ConfigureAwait(false), + }; + } + + private static string GetKey(string conversationId, string agentId) => $"{agentId}:{conversationId}"; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs new file mode 100644 index 0000000000..cc97049ae9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Converts Responses Server SDK input types to agent-framework types. +/// +internal static class InputConverter +{ + /// + /// Converts the SDK request input items into a list of . + /// + /// The create response request from the SDK. + /// A list of chat messages representing the request input. + public static List ConvertInputToMessages(CreateResponse request) + { + var messages = new List(); + + foreach (var item in request.GetInputExpanded()) + { + var message = ConvertInputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Converts resolved SDK input items into instances. + /// + /// The resolved input items from the SDK context. + /// A list of chat messages. + public static List ConvertItemsToMessages(IReadOnlyList items) + { + var messages = new List(); + + foreach (var item in items) + { + var message = ConvertInputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Converts resolved SDK history/input items into instances. + /// + /// The resolved output items from the SDK context. + /// A list of chat messages. + public static List ConvertOutputItemsToMessages(IReadOnlyList items) + { + var messages = new List(); + + foreach (var item in items) + { + var message = ConvertOutputItemToMessage(item); + if (message is not null) + { + messages.Add(message); + } + } + + return messages; + } + + /// + /// Creates from the SDK request properties. + /// + /// The create response request. + /// A configured instance. + public static ChatOptions ConvertToChatOptions(CreateResponse request) + { + return new ChatOptions + { + Temperature = (float?)request.Temperature, + TopP = (float?)request.TopP, + MaxOutputTokens = (int?)request.MaxOutputTokens, + // Note: We intentionally do NOT set ModelId from request.Model here. + // The hosted agent already has its own model configured, and passing + // the client-provided model would override it (causing failures when + // clients send placeholder values like "hosted-agent"). + }; + } + + /// + /// Extracts any Foundry Toolbox markers (foundry-toolbox://) from the request's + /// MCP tool entries so the handler can resolve them server-side. + /// + /// The create response request. + /// A list of (name, optional version) pairs, one per detected marker. Never . + public static List<(string Name, string? Version)> ReadMcpToolboxMarkers(CreateResponse request) + { + var markers = new List<(string Name, string? Version)>(); + + if (request.Tools is null) + { + return markers; + } + + foreach (var tool in request.Tools) + { + if (tool is not MCPTool mcp || mcp.ServerUrl is null) + { + continue; + } + + if (HostedMcpToolboxAITool.TryParseToolboxAddress(mcp.ServerUrl.ToString(), out var name, out var version)) + { + markers.Add((name!, version)); + } + } + + return markers; + } + + private static ChatMessage? ConvertInputItemToMessage(Item item) + { + return item switch + { + ItemMessage msg => ConvertItemMessage(msg), + FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput), + ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall), + ItemReferenceParam => null, + _ => null + }; + } + + private static ChatMessage ConvertItemMessage(ItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.GetContentExpanded()) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput) + { + var output = funcOutput.Output?.ToString() ?? string.Empty; + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, output)]); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK input.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK input.")] + private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage? ConvertOutputItemToMessage(OutputItem item) + { + return item switch + { + OutputItemMessage msg => ConvertOutputItemMessageToChat(msg), + OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall), + FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput), + OutputItemReasoningItem => null, + _ => null + }; + } + + private static ChatMessage ConvertOutputItemMessageToChat(OutputItemMessage msg) + { + var role = ConvertMessageRole(msg.Role); + var contents = new List(); + + foreach (var content in msg.Content) + { + switch (content) + { + case MessageContentInputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentOutputTextContent textContent: + contents.Add(new MeaiTextContent(textContent.Text)); + break; + case MessageContentRefusalContent refusal: + contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]")); + break; + case MessageContentInputImageContent imageContent: + if (imageContent.ImageUrl is not null) + { + var url = imageContent.ImageUrl.ToString(); + if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + contents.Add(new DataContent(url, "image/*")); + } + else + { + contents.Add(new UriContent(imageContent.ImageUrl, "image/*")); + } + } + else if (!string.IsNullOrEmpty(imageContent.FileId)) + { + contents.Add(new HostedFileContent(imageContent.FileId)); + } + + break; + case MessageContentInputFileContent fileContent: + if (fileContent.FileUrl is not null) + { + contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileData)) + { + contents.Add(new DataContent(fileContent.FileData, "application/octet-stream")); + } + else if (!string.IsNullOrEmpty(fileContent.FileId)) + { + contents.Add(new HostedFileContent(fileContent.FileId)); + } + else if (!string.IsNullOrEmpty(fileContent.Filename)) + { + contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]")); + } + + break; + } + } + + if (contents.Count == 0) + { + contents.Add(new MeaiTextContent(string.Empty)); + } + + return new ChatMessage(role, contents); + } + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")] + private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall) + { + IDictionary? arguments = null; + if (funcCall.Arguments is not null) + { + try + { + arguments = JsonSerializer.Deserialize>(funcCall.Arguments); + } + catch (JsonException) + { + arguments = new Dictionary { ["_raw"] = funcCall.Arguments }; + } + } + + return new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); + } + + private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput) + { + return new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]); + } + + private static ChatRole ConvertMessageRole(MessageRole role) + { + return role switch + { + MessageRole.User => ChatRole.User, + MessageRole.Assistant => ChatRole.Assistant, + MessageRole.System => ChatRole.System, + MessageRole.Developer => new ChatRole("developer"), + _ => ChatRole.User + }; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/McpConsentContext.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/McpConsentContext.cs new file mode 100644 index 0000000000..96ea08383b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/McpConsentContext.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Carries OAuth consent information for a single tool call that returned JSON-RPC error -32006. +/// +/// The toolbox name that owns the tool. +/// Fully-qualified tool name (e.g., logicapps.send_email). +/// The OAuth consent URL the user must visit. +internal sealed record McpConsentInfo(string ToolboxName, string ToolName, string ConsentUrl); + +/// +/// Per-request mutable state shared between (child context) +/// and (parent context) via . +/// +/// +/// Because only flows values DOWN from parent to children, +/// we use a shared reference type so children can mutate it and the parent observes the mutations. +/// +internal sealed class RequestConsentState +{ + /// Consent information set by the tool wrapper when -32006 is detected. + internal McpConsentInfo? Pending { get; set; } + + /// The linked CTS to cancel when consent is required. + internal CancellationTokenSource? CancellationSource { get; set; } +} + +/// +/// Async-local context that enables +/// to signal a consent error back to through the +/// tool loop. Flows with the async ExecutionContext. +/// +internal static class McpConsentContext +{ + /// + /// Holds the shared for the current request. + /// Set once by the handler; read and mutated by the tool wrapper. + /// + internal static readonly AsyncLocal Current = new(); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj new file mode 100644 index 0000000000..99ecde93ca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj @@ -0,0 +1,50 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Foundry.Hosting + preview + Microsoft Agent Framework for Foundry Hosted Agents + Provides Microsoft Agent Framework support for hosting Foundry Agents with the Azure AI Agent Service. + + + + true + true + true + true + $(NoWarn);OPENAI001;MEAI001;NU1903 + false + + + + + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs new file mode 100644 index 0000000000..58ba989ebf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Converts agent-framework streams into +/// Responses Server SDK sequences using the +/// builder pattern. +/// +internal static class OutputConverter +{ + /// + /// Converts a stream of into a stream of + /// using the SDK builder pattern. + /// + /// The agent response updates to convert. + /// The SDK event stream builder. + /// Cancellation token. + /// An async enumerable of SDK response stream events (excluding lifecycle events). + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")] + public static async IAsyncEnumerable ConvertUpdatesToEventsAsync( + IAsyncEnumerable updates, + ResponseEventStream stream, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ResponseUsage? accumulatedUsage = null; + OutputItemMessageBuilder? currentMessageBuilder = null; + TextContentBuilder? currentTextBuilder = null; + StringBuilder? accumulatedText = null; + string? previousMessageId = null; + bool hasTerminalEvent = false; + var executorItemIds = new Dictionary(); + + await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Handle workflow events from RawRepresentation + if (update.RawRepresentation is WorkflowEvent workflowEvent) + { + // Close any open message builder before emitting workflow items + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + foreach (var evt in EmitWorkflowEvent(stream, workflowEvent, executorItemIds)) + { + yield return evt; + } + + continue; + } + + foreach (var content in update.Contents) + { + switch (content) + { + case MeaiTextContent textContent: + { + if (!IsSameMessage(update.MessageId, previousMessageId) && currentMessageBuilder is not null) + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + } + + previousMessageId = update.MessageId; + + if (currentMessageBuilder is null) + { + currentMessageBuilder = stream.AddOutputItemMessage(); + yield return currentMessageBuilder.EmitAdded(); + + currentTextBuilder = currentMessageBuilder.AddTextContent(); + yield return currentTextBuilder.EmitAdded(); + + accumulatedText = new StringBuilder(); + } + + if (textContent.Text is { Length: > 0 }) + { + accumulatedText!.Append(textContent.Text); + yield return currentTextBuilder!.EmitDelta(textContent.Text); + } + + break; + } + + case FunctionCallContent funcCall: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var callId = funcCall.CallId ?? Guid.NewGuid().ToString("N"); + var funcBuilder = stream.AddOutputItemFunctionCall(funcCall.Name, callId); + yield return funcBuilder.EmitAdded(); + + var arguments = funcCall.Arguments is not null + ? JsonSerializer.Serialize(funcCall.Arguments) + : "{}"; + + yield return funcBuilder.EmitArgumentsDelta(arguments); + yield return funcBuilder.EmitArgumentsDone(arguments); + yield return funcBuilder.EmitDone(); + break; + } + + case TextReasoningContent reasoningContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + + var reasoningBuilder = stream.AddOutputItemReasoningItem(); + yield return reasoningBuilder.EmitAdded(); + + var summaryPart = reasoningBuilder.AddSummaryPart(); + yield return summaryPart.EmitAdded(); + + var text = reasoningContent.Text ?? string.Empty; + yield return summaryPart.EmitTextDelta(text); + yield return summaryPart.EmitTextDone(text); + yield return summaryPart.EmitDone(); + + yield return reasoningBuilder.EmitDone(); + break; + } + + case UsageContent usageContent when usageContent.Details is not null: + { + accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage); + break; + } + + case ErrorContent errorContent: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + previousMessageId = null; + hasTerminalEvent = true; + + yield return stream.EmitFailed( + ResponseErrorCode.ServerError, + errorContent.Message ?? "An error occurred during agent execution.", + accumulatedUsage); + yield break; + } + + case DataContent: + case UriContent: + // Image/audio/file content from agents is not currently supported + // as streaming output items in the Responses Server SDK builder pattern. + // These would need to be serialized as base64 or URL references. + break; + + case FunctionResultContent: + // Function results are internal to the agent's tool-calling loop + // and are not emitted as output items in the response stream. + break; + + default: + break; + } + } + } + + // Close any remaining open message + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText)) + { + yield return evt; + } + + if (!hasTerminalEvent) + { + yield return stream.EmitCompleted(accumulatedUsage); + } + } + + private static IEnumerable CloseCurrentMessage( + OutputItemMessageBuilder? messageBuilder, + TextContentBuilder? textBuilder, + StringBuilder? accumulatedText) + { + if (messageBuilder is null) + { + yield break; + } + + if (textBuilder is not null) + { + var finalText = accumulatedText?.ToString() ?? string.Empty; + yield return textBuilder.EmitTextDone(finalText); + yield return textBuilder.EmitDone(); + } + + yield return messageBuilder.EmitDone(); + } + + private static bool IsSameMessage(string? currentId, string? previousId) => + currentId is not { Length: > 0 } || previousId is not { Length: > 0 } || currentId == previousId; + + private static ResponseUsage ConvertUsage(UsageDetails details, ResponseUsage? existing) + { + var inputTokens = details.InputTokenCount ?? 0; + var outputTokens = details.OutputTokenCount ?? 0; + var totalTokens = details.TotalTokenCount ?? 0; + + if (existing is not null) + { + inputTokens += existing.InputTokens; + outputTokens += existing.OutputTokens; + totalTokens += existing.TotalTokens; + } + + return AzureAIAgentServerResponsesModelFactory.ResponseUsage( + inputTokens: inputTokens, + outputTokens: outputTokens, + totalTokens: totalTokens); + } + + private static IEnumerable EmitWorkflowEvent( + ResponseEventStream stream, + WorkflowEvent workflowEvent, + Dictionary executorItemIds) + { + switch (workflowEvent) + { + case ExecutorInvokedEvent invokedEvent: + { + var itemId = GenerateItemId("wfa"); + executorItemIds[invokedEvent.ExecutorId] = itemId; + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: invokedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.InProgress, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + break; + } + + case ExecutorCompletedEvent completedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: completedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Completed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(completedEvent.ExecutorId); + break; + } + + case ExecutorFailedEvent failedEvent: + { + var itemId = GenerateItemId("wfa"); + + var item = new WorkflowActionOutputItem( + kind: "InvokeExecutor", + actionId: failedEvent.ExecutorId, + status: WorkflowActionOutputItemStatus.Failed, + id: itemId); + + var builder = stream.AddOutputItem(itemId); + yield return builder.EmitAdded(item); + yield return builder.EmitDone(item); + executorItemIds.Remove(failedEvent.ExecutorId); + break; + } + + // Informational/lifecycle events — no SDK output needed. + // Note: AgentResponseUpdateEvent and WorkflowErrorEvent are unwrapped by + // WorkflowSession.InvokeStageAsync() into regular AgentResponseUpdate objects + // with populated Contents (TextContent, ErrorContent, etc.), so they flow + // through the normal content processing path above — not through this method. + case SuperStepStartedEvent: + case SuperStepCompletedEvent: + case WorkflowStartedEvent: + case WorkflowWarningEvent: + case RequestInfoEvent: + break; + } + } + + /// + /// Generates a valid item ID matching the SDK's {prefix}_{50chars} format. + /// + private static string GenerateItemId(string prefix) + { + // SDK format: {prefix}_{50 char body} + var bytes = RandomNumberGenerator.GetBytes(25); + var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase + return $"{prefix}_{body}"; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..dda822ef66 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ServiceCollectionExtensions.cs @@ -0,0 +1,261 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.Core; +using Azure.Identity; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Extension methods for registering agent-framework agents as Foundry Hosted Agents +/// using the Azure AI Responses Server SDK. +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryHostingExtensions +{ + /// + /// Registers the Azure AI Responses Server SDK and + /// as the . Agents are resolved from keyed DI services + /// using the agent.name or metadata["entity_id"] from incoming requests. + /// + /// + /// + /// This method calls AddResponsesServer() internally, so you do not need to + /// call it separately. Register your instances before calling this. + /// + /// + /// Example: + /// + /// builder.AddAIAgent("my-agent", ...); + /// builder.Services.AddFoundryResponses(); + /// + /// var app = builder.Build(); + /// app.MapFoundryResponses(); + /// + /// + /// + /// The service collection. + /// The service collection for chaining. + public static IServiceCollection AddFoundryResponses(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.AddResponsesServer(); + services.TryAddSingleton(); + services.TryAddSingleton(); + return services; + } + + /// + /// Registers the Azure AI Responses Server SDK and a specific + /// as the handler for all incoming requests, regardless of the agent.name in the request. + /// + /// + /// + /// Use this overload when hosting a single agent. The provided agent instance is + /// registered as both a keyed service and the default . + /// This method calls AddResponsesServer() internally. + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryResponses(myAgent); + /// + /// var app = builder.Build(); + /// app.MapFoundryResponses(); + /// + /// + /// + /// The service collection. + /// The agent instance to register. + /// The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used. + /// The service collection for chaining. + public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(agent); + + services.AddResponsesServer(); + agentSessionStore ??= new InMemoryAgentSessionStore(); + + if (!string.IsNullOrWhiteSpace(agent.Name)) + { + services.TryAddKeyedSingleton(agent.Name, agent); + services.TryAddKeyedSingleton(agent.Name, agentSessionStore); + } + + // Also register as the default (non-keyed) agent so requests + // without an agent name can resolve it (e.g., local dev tooling). + services.TryAddSingleton(agent); + services.TryAddSingleton(agentSessionStore); + + services.TryAddSingleton(); + return services; + } + + /// + /// Registers the Foundry Toolbox service, which eagerly connects to the Foundry Toolboxes + /// MCP proxy at startup and provides MCP tools to . + /// + /// + /// + /// Each string in is a toolbox name registered in the Foundry + /// project. The proxy URL per toolbox is constructed as: + /// {FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview + /// + /// + /// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent, startup succeeds without error and + /// no tools are loaded (the container remains healthy per spec §2). + /// + /// + /// Example: + /// + /// builder.Services.AddFoundryToolboxes("my-toolbox", "another-toolbox"); + /// + /// + /// + /// The service collection. + /// Names of the Foundry toolboxes to connect to. + /// The service collection for chaining. + public static IServiceCollection AddFoundryToolboxes( + this IServiceCollection services, + params string[] toolboxNames) + => services.AddFoundryToolboxes(configureOptions: null, toolboxNames); + + /// + /// Registers the Foundry Toolbox service with additional options configuration. + /// + /// The service collection. + /// Callback to further configure (e.g. set ). + /// Names of the Foundry toolboxes to pre-register at startup. + /// The service collection for chaining. + public static IServiceCollection AddFoundryToolboxes( + this IServiceCollection services, + Action? configureOptions, + params string[] toolboxNames) + { + ArgumentNullException.ThrowIfNull(services); + + services.Configure(opt => + { + foreach (var name in toolboxNames) + { + if (!string.IsNullOrWhiteSpace(name)) + { + opt.ToolboxNames.Add(name); + } + } + + configureOptions?.Invoke(opt); + }); + + // Register DefaultAzureCredential as the default TokenCredential if not already registered + services.TryAddSingleton(_ => new DefaultAzureCredential()); + + // Register FoundryToolboxService as a singleton so it can be injected into the handler + services.TryAddSingleton(); + + // AddHostedService uses TryAddEnumerable internally, so calling AddFoundryToolboxes + // multiple times will not invoke StartAsync twice on the same singleton. + services.AddHostedService(sp => sp.GetRequiredService()); + + return services; + } + + /// + /// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline. + /// + /// The endpoint route builder. + /// Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses). + /// The endpoint route builder for chaining. + public static IEndpointRouteBuilder MapFoundryResponses(this IEndpointRouteBuilder endpoints, string prefix = "") + { + ArgumentNullException.ThrowIfNull(endpoints); + endpoints.MapResponsesServer(prefix); + + if (endpoints is IApplicationBuilder app) + { + // Ensure the middleware is added to the pipeline + app.UseMiddleware(); + } + + return endpoints; + } + + /// + /// The ActivitySource name for the Responses hosting pipeline. + /// Matches the value previously exposed by AgentHostTelemetry.ResponsesSourceName + /// in Azure.AI.AgentServer.Core. + /// + private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses"; + + /// + /// Wraps with instrumentation + /// so that agent invocations emit spans into the pipeline registered by + /// Azure.AI.AgentServer.Core's AddAgentHostTelemetry(). + /// If the agent is already instrumented the original instance is returned unchanged. + /// + internal static AIAgent ApplyOpenTelemetry(AIAgent agent) + { + if (agent.GetService() is not null) + { + return agent; + } + + return agent.AsBuilder() + .UseOpenTelemetry(sourceName: ResponsesSourceName) + .Build(); + } + + private sealed class AgentFrameworkUserAgentMiddleware(RequestDelegate next) + { + private static readonly string s_userAgentValue = CreateUserAgentValue(); + + public async Task InvokeAsync(HttpContext context) + { + var headers = context.Request.Headers; + var userAgent = headers.UserAgent.ToString(); + + if (string.IsNullOrEmpty(userAgent)) + { + headers.UserAgent = s_userAgentValue; + } + else if (!userAgent.Contains(s_userAgentValue, StringComparison.OrdinalIgnoreCase)) + { + headers.UserAgent = $"{userAgent} {s_userAgentValue}"; + } + + await next(context).ConfigureAwait(false); + } + + private static string CreateUserAgentValue() + { + const string Name = "agent-framework-dotnet"; + + if (typeof(AgentFrameworkUserAgentMiddleware).Assembly.GetCustomAttribute()?.InformationalVersion is string version) + { + int pos = version.IndexOf('+'); + if (pos >= 0) + { + version = version.Substring(0, pos); + } + + if (version.Length > 0) + { + return $"{Name}/{version}"; + } + } + + return Name; + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs index 4383cfb6d4..1f0f0a6e5f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/AzureAIProjectChatClientExtensions.cs @@ -181,7 +181,7 @@ public static partial class AzureAIProjectChatClientExtensions /// Creates a non-versioned backed by the project's Responses API using the specified options. ///
/// The to use for Responses API calls. Cannot be . - /// Configuration options that control the agent's behavior. is required. + /// Optional configuration options that control the agent's behavior. /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for creating loggers used by the agent. /// An optional to use for resolving services required by the instances being invoked. @@ -190,15 +190,14 @@ public static partial class AzureAIProjectChatClientExtensions /// Thrown when does not specify . public static ChatClientAgent AsAIAgent( this AIProjectClient aiProjectClient, - ChatClientAgentOptions options, + ChatClientAgentOptions? options = null, Func? clientFactory = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null) { Throw.IfNull(aiProjectClient); - Throw.IfNull(options); - return CreateResponsesChatClientAgent(aiProjectClient, options, clientFactory, loggerFactory, services); + return CreateResponsesChatClientAgent(aiProjectClient, options ?? new(), clientFactory, loggerFactory, services); } #region Private diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs index 7721f8c013..a235cef664 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/FoundryAITool.cs @@ -112,6 +112,16 @@ public static class FoundryAITool public static AITool CreateA2ATool(Uri baseUri, string? agentCardPath = null) => ProjectsAgentTool.CreateA2ATool(baseUri, agentCardPath).AsAITool(); + /// + /// Creates an marker that references a Foundry Toolbox by name so + /// the hosted server side can resolve and expose its MCP tools for a single request. + /// + /// The Foundry toolbox name. + /// Optional pinned toolbox version. When , the project's default version is used. + /// An marker backed by . + public static AITool CreateHostedMcpToolbox(string toolboxName, string? version = null) + => new HostedMcpToolboxAITool(toolboxName, version); + // --- OpenAI SDK ResponseTool factories --- /// diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs new file mode 100644 index 0000000000..6af3ab2ff0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/HostedMcpToolboxAITool.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry; + +/// +/// A marker that identifies a Foundry Toolbox by name +/// (and optional version) on the OpenAI Responses mcp wire format. +/// +/// +/// +/// The hosted server recognizes this marker by its +/// scheme () and resolves it to the set of MCP tools exposed by the +/// matching toolbox registered in the Foundry project. +/// +/// +/// Callers should not construct this type directly. Use one of the +/// FoundryAITool.CreateHostedMcpToolbox(...) factory overloads. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public sealed class HostedMcpToolboxAITool : HostedMcpServerTool +{ + /// + /// The URI scheme used to identify Foundry Toolbox markers on the wire. + /// + public const string UriScheme = "foundry-toolbox"; + + /// + /// Initializes a new instance of the class. + /// + /// The Foundry toolbox name. + /// + /// Optional pinned toolbox version. When , the project's default version is used. + /// Currently reserved for forward compatibility — version-specific routing is handled server-side by + /// the Foundry proxy. + /// + public HostedMcpToolboxAITool(string toolboxName, string? version = null) + : base( + serverName: NotNullOrWhitespace(toolboxName, nameof(toolboxName)), + serverAddress: BuildAddress(toolboxName, version)) + { + this.ToolboxName = toolboxName; + this.Version = version; + } + + /// + /// Gets the Foundry toolbox name. + /// + public string ToolboxName { get; } + + /// + /// Gets the pinned toolbox version, or to use the project's default. + /// + public string? Version { get; } + + /// + /// Builds the toolbox marker address: foundry-toolbox://{name}[?version={v}]. + /// + public static string BuildAddress(string toolboxName, string? version) + { + _ = NotNullOrWhitespace(toolboxName, nameof(toolboxName)); + + return string.IsNullOrEmpty(version) + ? $"{UriScheme}://{toolboxName}" + : $"{UriScheme}://{toolboxName}?version={Uri.EscapeDataString(version)}"; + } + + /// + /// Attempts to parse a toolbox marker address into its name and optional version components. + /// + /// The to inspect. + /// When this method returns , the parsed toolbox name. + /// When this method returns , the optional version, or . + /// if is a Foundry toolbox marker; otherwise . + public static bool TryParseToolboxAddress( + string? address, + [NotNullWhen(true)] out string? toolboxName, + out string? version) + { + toolboxName = null; + version = null; + + if (string.IsNullOrEmpty(address)) + { + return false; + } + + if (!Uri.TryCreate(address, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!string.Equals(uri.Scheme, UriScheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // For foundry-toolbox://name, the name appears as Authority (host) with an empty path. + // For foundry-toolbox:name (rare), it falls through to PathAndQuery. + var name = uri.Host; + if (string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(uri.AbsolutePath)) + { + name = uri.AbsolutePath.TrimStart('/'); + } + + if (string.IsNullOrEmpty(name)) + { + return false; + } + + toolboxName = name; + + var query = uri.Query; + if (!string.IsNullOrEmpty(query)) + { + // Minimal parser to avoid a HttpUtility dependency on netstandard. + foreach (var part in query.TrimStart('?').Split('&')) + { + var eq = part.IndexOf('='); + if (eq <= 0) + { + continue; + } + + var key = part.Substring(0, eq); + if (string.Equals(key, "version", StringComparison.OrdinalIgnoreCase)) + { + version = Uri.UnescapeDataString(part.Substring(eq + 1)); + break; + } + } + } + + return true; + } + + private static string NotNullOrWhitespace(string value, string paramName) + { + if (value is null) + { + throw new ArgumentNullException(paramName); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Value cannot be empty or whitespace.", paramName); + } + + return value; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj index 6da65fafe6..cd4200df75 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj @@ -23,6 +23,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs new file mode 100644 index 0000000000..d6fbd53df5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/HostedMcpToolboxAIToolTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +public class HostedMcpToolboxAIToolTests +{ + [Fact] + public void Ctor_NameOnly_BuildsMarkerAddress() + { + var tool = new HostedMcpToolboxAITool("my-toolbox"); + + Assert.Equal("my-toolbox", tool.ToolboxName); + Assert.Null(tool.Version); + Assert.Equal("my-toolbox", tool.ServerName); + Assert.Equal("foundry-toolbox://my-toolbox", tool.ServerAddress); + Assert.Equal("mcp", tool.Name); + } + + [Fact] + public void Ctor_WithVersion_IncludesVersionQuery() + { + var tool = new HostedMcpToolboxAITool("my-toolbox", "v3"); + + Assert.Equal("v3", tool.Version); + Assert.Equal("foundry-toolbox://my-toolbox?version=v3", tool.ServerAddress); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Ctor_InvalidName_Throws(string? name) + { + Assert.ThrowsAny(() => new HostedMcpToolboxAITool(name!)); + } + + [Fact] + public void TryParseToolboxAddress_NameOnly_ReturnsTrue() + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress( + "foundry-toolbox://my-toolbox", out var name, out var version); + + Assert.True(ok); + Assert.Equal("my-toolbox", name); + Assert.Null(version); + } + + [Fact] + public void TryParseToolboxAddress_WithVersion_ExtractsVersion() + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress( + "foundry-toolbox://my-toolbox?version=v3", out var name, out var version); + + Assert.True(ok); + Assert.Equal("my-toolbox", name); + Assert.Equal("v3", version); + } + + [Theory] + [InlineData("https://example.com/mcp")] + [InlineData("not-a-url")] + [InlineData("")] + [InlineData(null)] + public void TryParseToolboxAddress_NonMarker_ReturnsFalse(string? address) + { + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version); + + Assert.False(ok); + Assert.Null(name); + Assert.Null(version); + } + + [Fact] + public void TryParseToolboxAddress_RoundTripsFromBuild() + { + var address = HostedMcpToolboxAITool.BuildAddress("box", "2025-06-01"); + + var ok = HostedMcpToolboxAITool.TryParseToolboxAddress(address, out var name, out var version); + + Assert.True(ok); + Assert.Equal("box", name); + Assert.Equal("2025-06-01", version); + } + + [Fact] + public void FoundryAITool_CreateHostedMcpToolbox_ReturnsMarker() + { + var tool = FoundryAITool.CreateHostedMcpToolbox("my-toolbox", "v1"); + + var marker = Assert.IsType(tool); + Assert.Equal("my-toolbox", marker.ToolboxName); + Assert.Equal("v1", marker.Version); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs new file mode 100644 index 0000000000..9b17fa9fae --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OpenTelemetry; +using OpenTelemetry.Trace; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Tests that verify OTel spans are actually emitted and captured through the +/// pipeline when +/// wraps the resolved agent. +/// +public class AgentFrameworkResponseHandlerTelemetryTests +{ + /// + /// The ActivitySource name used by ApplyOpenTelemetry() — equals AgentHostTelemetry.ResponsesSourceName. + /// Declared as a constant so the TracerProvider and assertions reference the same literal. + /// + private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses"; + + [Fact] + public async Task CreateAsync_DefaultAgent_EmitsInvokeAgentSpanAsync() + { + // Arrange + var activities = new List(); + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(ResponsesSourceName) + .AddInMemoryExporter(activities) + .Build(); + + var agent = new TelemetryTestAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var (request, context) = BuildRequest(); + + // Act — enumerate all events so the span completes before asserting + await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { } + + // Assert — filter by agent name to isolate this test's span from any parallel test spans + var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList()); + Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name")); + Assert.NotNull(mySpan.GetTagItem("gen_ai.agent.id")); + } + + [Fact] + public async Task CreateAsync_KeyedAgent_EmitsInvokeAgentSpanAsync() + { + // Arrange + var activities = new List(); + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(ResponsesSourceName) + .AddInMemoryExporter(activities) + .Build(); + + var agent = new TelemetryTestAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("keyed-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var (request, context) = BuildRequest(agentKey: "keyed-agent"); + + // Act + await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { } + + // Assert — filter by agent name to isolate this test's span + var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList()); + Assert.Equal("invoke_agent", mySpan.GetTagItem("gen_ai.operation.name")); + } + + [Fact] + public async Task CreateAsync_AlreadyInstrumentedAgent_EmitsSingleSpanPerRunAsync() + { + // Arrange — use a unique source for the pre-wrapped agent distinct from ResponsesSourceName. + // If ApplyOpenTelemetry double-wraps, an extra span would appear on ResponsesSourceName. + // If it correctly skips wrapping, only the pre-wrap's unique source emits spans. + var preWrapSource = Guid.NewGuid().ToString(); + var preWrapActivities = new List(); + var responsesActivities = new List(); + + using var preWrapProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(preWrapSource) + .AddInMemoryExporter(preWrapActivities) + .Build(); + + using var responsesProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(ResponsesSourceName) + .AddInMemoryExporter(responsesActivities) + .Build(); + + var innerAgent = new TelemetryTestAgent(); + var preWrapped = innerAgent.AsBuilder() + .UseOpenTelemetry(sourceName: preWrapSource) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(preWrapped); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + // Act + var (request, context) = BuildRequest(); + await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { } + + // Assert — pre-wrap source emits exactly 1 span (agent ran) + Assert.Single(preWrapActivities); + Assert.Equal("invoke_agent", preWrapActivities[0].GetTagItem("gen_ai.operation.name")); + + // ResponsesSourceName emits 0 spans — ApplyOpenTelemetry skipped wrapping the pre-instrumented agent + Assert.DoesNotContain(responsesActivities, a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))); + } + + [Fact] + public async Task CreateAsync_DefaultAgent_SpanDisplayNameContainsAgentNameAsync() + { + // Arrange + var activities = new List(); + using var tracerProvider = Sdk.CreateTracerProviderBuilder() + .AddSource(ResponsesSourceName) + .AddInMemoryExporter(activities) + .Build(); + + var agent = new TelemetryTestAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var (request, context) = BuildRequest(); + + // Act + await foreach (var _ in handler.CreateAsync(request, context, CancellationToken.None)) { } + + // Assert — display name follows "invoke_agent {Name}({Id})" convention; filter by agent name to isolate + var mySpan = Assert.Single(activities.Where(a => TelemetryTestAgent.AgentName.Equals(a.GetTagItem("gen_ai.agent.name"))).ToList()); + Assert.Contains("invoke_agent", mySpan.DisplayName, StringComparison.Ordinal); + Assert.Contains(TelemetryTestAgent.AgentName, mySpan.DisplayName, StringComparison.Ordinal); + } + + private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null) + { + var request = agentKey is null + ? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test") + : AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference(agentKey)); + + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync([]); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + return (request, mockContext.Object); + } + + private sealed class TelemetryTestAgent : AIAgent + { + public const string AgentName = "TelemetryTestAgent"; + + public override string? Name => AgentName; + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + SingleUpdateAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent("telemetry test response")] + }, cancellationToken); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new TelemetryAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new TelemetryAgentSession()); + + private static async IAsyncEnumerable SingleUpdateAsync( + AgentResponseUpdate update, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return update; + } + } + + private sealed class TelemetryAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs new file mode 100644 index 0000000000..75a495f05b --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs @@ -0,0 +1,870 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class AgentFrameworkResponseHandlerTests +{ + [Fact] + public async Task CreateAsync_WithDefaultAgent_ProducesStreamEventsAsync() + { + // Arrange + var agent = CreateTestAgent("Hello from the agent!"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + [Fact] + public async Task CreateAsync_WithKeyedAgent_ResolvesCorrectAgentAsync() + { + // Arrange + var agent = CreateTestAgent("Keyed agent response"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert - should have produced events from the keyed agent + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentRegistered_ThrowsInvalidOperationExceptionAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + } + + [Fact] + public void Constructor_NullServiceProvider_ThrowsArgumentNullException() + { + Assert.Throws( + () => new AgentFrameworkResponseHandler(null!, NullLogger.Instance)); + } + + [Fact] + public void Constructor_NullLogger_ThrowsArgumentNullException() + { + var sp = new ServiceCollection().BuildServiceProvider(); + Assert.Throws( + () => new AgentFrameworkResponseHandler(sp, null!)); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByModelFieldAsync() + { + // Arrange + var agent = CreateTestAgent("model agent"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("my-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_ResolvesAgentByEntityIdMetadataAsync() + { + // Arrange + var agent = CreateTestAgent("entity agent"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("entity-agent", agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + var metadata = new Metadata(); + metadata.AdditionalProperties["entity_id"] = "entity-agent"; + request.Metadata = metadata; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NamedAgentNotFound_FallsBackToDefaultAsync() + { + // Arrange + var agent = CreateTestAgent("default agent"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("nonexistent-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_NoAgentFound_ErrorMessageIncludesAgentNameAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("missing-agent")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("missing-agent", ex.Message); + } + + [Fact] + public async Task CreateAsync_NoAgentNoName_ErrorMessageIsGenericAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + } + }); + + Assert.Contains("No agent name specified", ex.Message); + } + + [Fact] + public async Task CreateAsync_AgentResolvedBeforeEmitCreated_ExceptionHasNoEventsAsync() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + bool threw = false; + try + { + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + } + catch (InvalidOperationException) + { + threw = true; + } + + // Assert + Assert.True(threw); + Assert.Empty(events); + } + + [Fact] + public async Task CreateAsync_WithHistory_PrependsHistoryToMessagesAsync() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var historyItem = new OutputItemMessage( + id: "hist_1", + role: MessageRole.Assistant, + content: [new MessageContentOutputTextContent( + "Previous response", + Array.Empty(), + Array.Empty())], + status: MessageStatus.Completed); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(new OutputItem[] { historyItem }); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.True(messages.Count >= 2); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_WithInputItems_UsesResolvedInputItemsAsync() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var inputItem = new ItemMessage( + MessageRole.Assistant, + [new MessageContentInputTextContent("Resolved input")]); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Item[] { inputItem }); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_NoInputItems_FallsBackToRawRequestInputAsync() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Raw input" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedMessages); + var messages = agent.CapturedMessages.ToList(); + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public async Task CreateAsync_PassesInstructionsToAgentAsync() + { + // Arrange + var agent = new CapturingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + instructions: "You are a helpful assistant."); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.NotNull(agent.CapturedOptions); + var chatClientOptions = Assert.IsType(agent.CapturedOptions); + Assert.Equal("You are a helpful assistant.", chatClientOptions.ChatOptions?.Instructions); + } + + [Fact] + public async Task CreateAsync_AgentThrows_EmitsFailedEventWithErrorMessageAsync() + { + // Arrange + var agent = new ThrowingAgent(new InvalidOperationException("Agent crashed")); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act — collect all events + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert — should contain created, in_progress, and failed (with real error message) + Assert.Contains(events, e => e is ResponseCreatedEvent); + Assert.Contains(events, e => e is ResponseInProgressEvent); + var failedEvent = Assert.Single(events.OfType()); + Assert.Contains("Agent crashed", failedEvent.Response.Error.Message); + } + + [Fact] + public async Task CreateAsync_MultipleKeyedAgents_ResolvesCorrectOneAsync() + { + // Arrange + var agent1 = CreateTestAgent("Agent 1 response"); + var agent2 = CreateTestAgent("Agent 2 response"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("agent-1", agent1); + services.AddKeyedSingleton("agent-2", agent2); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("agent-2")); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert + Assert.True(events.Count >= 4); + Assert.IsType(events[0]); + } + + [Fact] + public async Task CreateAsync_CancellationDuringExecution_PropagatesOperationCanceledExceptionAsync() + { + // Arrange + var agent = new CancellationCheckingAgent(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act & Assert + await Assert.ThrowsAsync(async () => + { + await foreach (var _ in handler.CreateAsync(request, mockContext.Object, cts.Token)) + { + } + }); + } + + [Fact] + public async Task CreateAsync_DefaultAgent_IsAutoWrappedWithOpenTelemetryAsync() + { + // Arrange — register a plain (non-instrumented) agent + var agent = CreateTestAgent("otel test response"); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } } + }); + + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mockContext.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mockContext.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act — OTel wrapping must not break the stream + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, mockContext.Object, CancellationToken.None)) + { + events.Add(evt); + } + + // Assert — stream events are still produced correctly through the wrapper + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[1]); + } + + private static TestAgent CreateTestAgent(string responseText) + { + return new TestAgent(responseText); + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(params AgentResponseUpdate[] items) + { + foreach (var item in items) + { + yield return item; + } + + await Task.CompletedTask; + } + + private sealed class TestAgent(string responseText) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent(responseText)] + }); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + } + + private sealed class ThrowingAgent(Exception exception) : AIAgent + { + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + } + + private sealed class CapturingAgent : AIAgent + { + public IEnumerable? CapturedMessages { get; private set; } + public AgentRunOptions? CapturedOptions { get; private set; } + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) + { + this.CapturedMessages = messages.ToList(); + this.CapturedOptions = options; + return ToAsyncEnumerableAsync(new AgentResponseUpdate + { + MessageId = "resp_msg_1", + Contents = [new MeaiTextContent("captured")] + }); + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + } + + private sealed class CancellationCheckingAgent : AIAgent + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] }; + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(JsonDocument.Parse("{}").RootElement); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + new(new SimpleAgentSession()); + } + + private sealed class SimpleAgentSession : AgentSession { } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryAIToolExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryAIToolExtensionsTests.cs new file mode 100644 index 0000000000..f083a9946e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryAIToolExtensionsTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Foundry.Hosting; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryAIToolExtensionsTests +{ + [Fact] + public void CreateHostedMcpToolbox_FromToolboxRecord_UsesNameAndDefaultVersion() + { + var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord( + id: "tbx-123", + name: "calendar-tools", + defaultVersion: "v2"); + + var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record); + + var marker = Assert.IsType(tool); + Assert.Equal("calendar-tools", marker.ToolboxName); + Assert.Equal("v2", marker.Version); + Assert.Equal("foundry-toolbox://calendar-tools?version=v2", marker.ServerAddress); + } + + [Fact] + public void CreateHostedMcpToolbox_FromToolboxRecord_NullDefaultVersionOmitsQuery() + { + var record = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxRecord( + id: "tbx-abc", + name: "finance-tools", + defaultVersion: null); + + var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(record); + + var marker = Assert.IsType(tool); + Assert.Equal("finance-tools", marker.ToolboxName); + Assert.Null(marker.Version); + Assert.Equal("foundry-toolbox://finance-tools", marker.ServerAddress); + } + + [Fact] + public void CreateHostedMcpToolbox_FromToolboxRecord_Null_Throws() + { + Assert.Throws( + () => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxRecord)null!)); + } + + [Fact] + public void CreateHostedMcpToolbox_FromToolboxVersion_UsesNameAndVersion() + { + var version = Azure.AI.Projects.Agents.ProjectsAgentsModelFactory.ToolboxVersion( + metadata: null, + id: "ver-1", + name: "hr-tools", + version: "2025-09-01", + description: "HR toolbox", + createdAt: DateTimeOffset.UtcNow, + tools: null, + policies: null); + + var tool = FoundryAIToolExtensions.CreateHostedMcpToolbox(version); + + var marker = Assert.IsType(tool); + Assert.Equal("hr-tools", marker.ToolboxName); + Assert.Equal("2025-09-01", marker.Version); + Assert.Equal("foundry-toolbox://hr-tools?version=2025-09-01", marker.ServerAddress); + } + + [Fact] + public void CreateHostedMcpToolbox_FromToolboxVersion_Null_Throws() + { + Assert.Throws( + () => FoundryAIToolExtensions.CreateHostedMcpToolbox((Azure.AI.Projects.Agents.ToolboxVersion)null!)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs new file mode 100644 index 0000000000..cea48d8eb0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxBearerTokenHandlerTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Foundry.Hosting; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryToolboxBearerTokenHandlerTests +{ + private const string FakeToken = "test-bearer-token"; + + private static Mock CreateMockCredential() + { + var mock = new Mock(); + mock.Setup(c => c.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.UtcNow.AddHours(1))); + return mock; + } + + private static (FoundryToolboxBearerTokenHandler Handler, CountingHandler Inner) CreateHandlerPair( + Mock? credential = null, + string? featuresHeader = null, + HttpStatusCode statusCode = HttpStatusCode.OK) + { + credential ??= CreateMockCredential(); + var inner = new CountingHandler(statusCode); + var handler = new FoundryToolboxBearerTokenHandler(credential.Object, featuresHeader) + { + InnerHandler = inner + }; + return (handler, inner); + } + + [Fact] + public async Task SendAsync_InjectsBearerTokenAsync() + { + var (handler, _) = CreateHandlerPair(); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Bearer", request.Headers.Authorization?.Scheme); + Assert.Equal(FakeToken, request.Headers.Authorization?.Parameter); + } + + [Fact] + public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2"); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values)); + Assert.Contains("feature1,feature2", values); + } + + [Fact] + public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync() + { + var (handler, _) = CreateHandlerPair(featuresHeader: null); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.False(request.Headers.Contains("Foundry-Features")); + } + + [Theory] + [InlineData(HttpStatusCode.OK)] + [InlineData(HttpStatusCode.Created)] + [InlineData(HttpStatusCode.BadRequest)] + [InlineData(HttpStatusCode.NotFound)] + public async Task SendAsync_NonRetryableStatusCode_ReturnsImmediatelyAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(statusCode, response.StatusCode); + Assert.Equal(1, inner.CallCount); + } + + [Theory] + [InlineData(HttpStatusCode.TooManyRequests)] + [InlineData(HttpStatusCode.InternalServerError)] + [InlineData(HttpStatusCode.BadGateway)] + [InlineData(HttpStatusCode.ServiceUnavailable)] + public async Task SendAsync_RetryableStatusCode_RetriesMaxTimesAsync(HttpStatusCode statusCode) + { + var (handler, inner) = CreateHandlerPair(statusCode: statusCode); + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + // MaxRetries is 3, so exactly 3 total attempts (not 4). + Assert.Equal(3, inner.CallCount); + Assert.Equal(statusCode, response.StatusCode); + } + + [Fact] + public async Task SendAsync_RetryableStatusCode_SucceedsOnSecondAttemptAsync() + { + // First call returns 503, second returns 200. + var inner = new SequenceHandler( + HttpStatusCode.ServiceUnavailable, + HttpStatusCode.OK); + + var handler = new FoundryToolboxBearerTokenHandler(CreateMockCredential().Object, null) + { + InnerHandler = inner + }; + using var invoker = new HttpMessageInvoker(handler); + + using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api"); + using var response = await invoker.SendAsync(request, CancellationToken.None); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(2, inner.CallCount); + } + + /// + /// A test handler that always returns the configured status code and counts how many times it was called. + /// + private sealed class CountingHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private int _callCount; + + public int CallCount => this._callCount; + + public CountingHandler(HttpStatusCode statusCode) + { + this._statusCode = statusCode; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Interlocked.Increment(ref this._callCount); + return Task.FromResult(new HttpResponseMessage(this._statusCode)); + } + } + + /// + /// A test handler that returns status codes from a sequence, cycling through them. + /// + private sealed class SequenceHandler : HttpMessageHandler + { + private readonly HttpStatusCode[] _statusCodes; + private int _callCount; + + public int CallCount => this._callCount; + + public SequenceHandler(params HttpStatusCode[] statusCodes) + { + this._statusCodes = statusCodes; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var index = Interlocked.Increment(ref this._callCount) - 1; + var statusCode = index < this._statusCodes.Length + ? this._statusCodes[index] + : this._statusCodes[^1]; + return Task.FromResult(new HttpResponseMessage(statusCode)); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs new file mode 100644 index 0000000000..24f7433c4e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxServiceTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.Options; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class FoundryToolboxServiceTests +{ + [Fact] + public async Task GetToolboxToolsAsync_StrictMode_ThrowsForUnknownToolboxAsync() + { + var options = new FoundryToolboxOptions { StrictMode = true }; + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Act + Assert: no StartAsync so Tools is empty; unknown name in strict mode throws. + var ex = await Assert.ThrowsAsync( + async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None)); + + Assert.Contains("missing", ex.Message, StringComparison.Ordinal); + Assert.Contains("StrictMode", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task GetToolboxToolsAsync_NonStrictMode_RequiresEndpointAsync() + { + var options = new FoundryToolboxOptions { StrictMode = false }; + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + // Without calling StartAsync, endpoint is not resolved so lazy-open fails clearly. + var ex = await Assert.ThrowsAsync( + async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None)); + + Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync() + { + // Ensure env var is not set (tests may run in any CI environment) + var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT"); + Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null); + try + { + var options = new FoundryToolboxOptions(); + options.ToolboxNames.Add("any"); + var service = new FoundryToolboxService( + Options.Create(options), + Mock.Of()); + + await service.StartAsync(CancellationToken.None); + + Assert.Empty(service.Tools); + } + finally + { + Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs new file mode 100644 index 0000000000..e2f6159a6e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -0,0 +1,767 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class InputConverterTests +{ + [Fact] + public void ConvertInputToMessages_EmptyRequest_ReturnsEmptyList() + { + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(Array.Empty()); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertInputToMessages_UserTextMessage_ReturnsUserMessage() + { + var input = new[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello, agent!" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello, agent!"); + } + + [Fact] + public void ConvertInputToMessages_FunctionCallOutput_ReturnsToolMessage() + { + var input = new[] + { + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_123", + output = "42" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var funcResult = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcResult); + Assert.Equal("call_123", funcResult.CallId); + } + + [Fact] + public void ConvertInputToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var input = new[] + { + new + { + type = "function_call", + id = "fc_001", + call_id = "call_456", + name = "get_weather", + arguments = "{\"location\": \"Seattle\"}" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var funcCall = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(funcCall); + Assert.Equal("call_456", funcCall.CallId); + Assert.Equal("get_weather", funcCall.Name); + } + + [Fact] + public void ConvertInputToMessages_MultipleItems_ReturnsAllMessages() + { + var input = new object[] + { + new + { + type = "message", + id = "msg_001", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "What's the weather?" } } + }, + new + { + type = "function_call", + id = "fc_001", + call_id = "call_789", + name = "get_weather", + arguments = "{}" + }, + new + { + type = "function_call_output", + id = "fc_out_001", + call_id = "call_789", + output = "Sunny, 72°F" + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Equal(3, messages.Count); + Assert.Equal(ChatRole.User, messages[0].Role); + Assert.Equal(ChatRole.Assistant, messages[1].Role); + Assert.Equal(ChatRole.Tool, messages[2].Role); + } + + [Fact] + public void ConvertToChatOptions_SetsTemperatureAndTopP() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + temperature: 0.7, + topP: 0.9, + maxOutputTokens: 1000, + model: "gpt-4o"); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Equal(0.7f, options.Temperature); + Assert.Equal(0.9f, options.TopP); + Assert.Equal(1000, options.MaxOutputTokens); + Assert.Null(options.ModelId); + } + + [Fact] + public void ConvertToChatOptions_NullValues_SetsNulls() + { + var request = new CreateResponse(); + + var options = InputConverter.ConvertToChatOptions(request); + + Assert.Null(options.Temperature); + Assert.Null(options.TopP); + Assert.Null(options.MaxOutputTokens); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessage_ReturnsAssistantMessage() + { + var textContent = new MessageContentOutputTextContent( + "Hello from assistant", + Array.Empty(), + Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "out_001", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text == "Hello from assistant"); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCall_ReturnsAssistantMessage() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_abc", + name: "search", + arguments: "{\"query\": \"test\"}"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.Equal("call_abc", content.CallId); + Assert.Equal("search", content.Name); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage() + { + var funcOutput = new FunctionToolCallOutputResource( + callId: "call_def", + output: BinaryData.FromString("result data")); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Tool, messages[0].Role); + var result = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(result); + Assert.Equal("call_def", result.CallId); + } + + [Fact] + public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull() + { + var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem( + id: "reason_001"); + + var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]); + + Assert.Empty(messages); + } + + // ── Image Content Tests (B-03 through B-06) ── + + [Fact] + public void ConvertInputToMessages_ImageContentWithHttpUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "https://example.com/img.png" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithDataUri_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", image_url = "data:image/png;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image", file_id = "file_abc123" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_ImageContentNoUrlOrFileId_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_image" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── File Content Tests (B-07 through B-11) ── + + [Fact] + public void ConvertInputToMessages_FileContentWithUrl_ReturnsUriContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_url = "https://example.com/doc.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is UriContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithInlineData_ReturnsDataContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_data = "data:application/pdf;base64,iVBORw0KGgo=" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is DataContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFileId_ReturnsHostedFileContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", file_id = "file_xyz789" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is HostedFileContent); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithFilenameOnly_ReturnsFallbackText() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file", filename = "report.pdf" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("report.pdf")); + } + + [Fact] + public void ConvertInputToMessages_FileContentWithNothing_ProducesNoContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_file" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Single(messages[0].Contents); + } + + // ── Mixed Content / Edge Cases (B-15 through B-18) ── + + [Fact] + public void ConvertInputToMessages_MixedContentInSingleMessage_ReturnsAllContentTypes() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new object[] + { + new { type = "input_text", text = "Look at this:" }, + new { type = "input_image", image_url = "https://example.com/img.png" } + } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(2, messages[0].Contents.Count); + } + + [Fact] + public void ConvertInputToMessages_EmptyMessageContent_ReturnsFallbackTextContent() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = Array.Empty() + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("I cannot help with that"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("I cannot help with that")); + } + + [Fact] + public void ConvertInputToMessages_ItemReferenceParam_IsSkipped() + { + var input = new object[] + { + new { type = "item_reference", id = "ref_001" }, + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hello" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + } + + // ── Role Mapping Tests (C-01 through C-05) ── + + [Fact] + public void ConvertInputToMessages_UserRole_ReturnsChatRoleUser() + { + var input = new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Hi" } } + } + }; + + var request = new CreateResponse(); + request.Input = BinaryData.FromObjectAsJson(input); + + var messages = InputConverter.ConvertInputToMessages(request); + + Assert.Single(messages); + Assert.Equal(ChatRole.User, messages[0].Role); + } + + [Fact] + public void ConvertOutputItemsToMessages_AssistantRole_ReturnsChatRoleAssistant() + { + // OutputItemMessage always maps to assistant role + var textContent = new MessageContentOutputTextContent( + "Hi", Array.Empty(), Array.Empty()); + var outputMsg = new OutputItemMessage( + id: "msg_1", + role: MessageRole.Assistant, + content: [textContent], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + } + + // ── History Conversion Edge Cases (D-02 through D-12) ── + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithRefusal_ReturnsRefusalText() + { + var refusal = new MessageContentRefusalContent("Not allowed"); + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [refusal], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + Assert.Equal(ChatRole.Assistant, messages[0].Role); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("[Refusal:")); + Assert.Contains(messages[0].Contents, c => c is MeaiTextContent tc && tc.Text!.Contains("Not allowed")); + } + + [Fact] + public void ConvertOutputItemsToMessages_OutputMessageWithEmptyContent_ReturnsFallbackText() + { + var outputMsg = new OutputItemMessage( + id: "out_1", + role: MessageRole.Assistant, + content: [], + status: MessageStatus.Completed); + + var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]); + + Assert.Single(messages); + var textContent = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal(string.Empty, textContent.Text); + } + + [Fact] + public void ConvertOutputItemsToMessages_FunctionToolCallWithMalformedArgs_UsesRawFallback() + { + var funcCall = new OutputItemFunctionToolCall( + callId: "call_1", + name: "test", + arguments: "not-json{{{"); + + var messages = InputConverter.ConvertOutputItemsToMessages([funcCall]); + + Assert.Single(messages); + var content = messages[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(content); + Assert.NotNull(content.Arguments); + Assert.True(content.Arguments.ContainsKey("_raw")); + } + + [Fact] + public void ConvertOutputItemsToMessages_UnknownOutputItemType_IsSkipped() + { + var messages = InputConverter.ConvertOutputItemsToMessages([]); + + Assert.Empty(messages); + } + + [Fact] + public void ConvertToChatOptions_ModelId_NotSetFromRequest() + { + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model"); + + var options = InputConverter.ConvertToChatOptions(request); + + // Model from the request is intentionally NOT propagated — the hosted agent uses its own model. + Assert.Null(options.ModelId); + } + + // ── ReadMcpToolboxMarkers tests ────────────────────────────────────────────── + + [Fact] + public void ReadMcpToolboxMarkers_NullTools_ReturnsEmpty() + { + var request = new CreateResponse(); + // Tools defaults to null when not set via JSON deserialization. + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithToolboxAddress_ReturnsMarker() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Null(markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithVersionedAddress_ReturnsNameAndVersion() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test-toolbox") + { + ServerUrl = new Uri("foundry-toolbox://my-toolbox?version=v3") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Single(markers); + Assert.Equal("my-toolbox", markers[0].Name); + Assert.Equal("v3", markers[0].Version); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNonToolboxUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external-mcp") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_McpToolWithNullServerUrl_SkipsIt() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("test") { ServerUrl = null }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Empty(markers); + } + + [Fact] + public void ReadMcpToolboxMarkers_MixedTools_ReturnsOnlyToolboxMarkers() + { + var request = new CreateResponse(); + request.Tools.Add(new MCPTool("external") + { + ServerUrl = new Uri("https://example.com/mcp") + }); + request.Tools.Add(new MCPTool("toolbox-1") + { + ServerUrl = new Uri("foundry-toolbox://box-a") + }); + request.Tools.Add(new MCPTool("toolbox-2") + { + ServerUrl = new Uri("foundry-toolbox://box-b?version=2025-01") + }); + + var markers = InputConverter.ReadMcpToolboxMarkers(request); + + Assert.Equal(2, markers.Count); + Assert.Equal("box-a", markers[0].Name); + Assert.Null(markers[0].Version); + Assert.Equal("box-b", markers[1].Name); + Assert.Equal("2025-01", markers[1].Version); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs new file mode 100644 index 0000000000..876339ea2c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs @@ -0,0 +1,1081 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class OutputConverterTests +{ + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyStream_EmitsCompletedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = ToAsync(Array.Empty()); + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_SingleTextUpdate_EmitsMessageAndCompletedAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hello, world!")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Expected: MessageAdded, TextAdded, TextDelta, TextDone, ContentDone, MessageDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleTextUpdates_EmitsStreamingDeltasAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hello, ")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("world!")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have two text delta events among the others + Assert.True(events.Count >= 6, $"Expected at least 6 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCall_EmitsFunctionCallEventsAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "get_weather", + new Dictionary { ["city"] = "Seattle" })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: FuncAdded, ArgsDelta, ArgsDone, FuncDone, Completed + Assert.IsType(events[0]); + Assert.IsType(events[^1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events for function call, got {events.Count}"); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_EmitsFailedAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Something went wrong")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContent_DoesNotEmitCompletedAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new ErrorContent("Failure")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageContent_IncludesUsageInCompletedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate + { + MessageId = "msg_1", + Contents = [new MeaiTextContent("Hi")] + }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails + { + InputTokenCount = 10, + OutputTokenCount = 5, + TotalTokenCount = 15 + })] + } + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + var completedEvent = events.OfType().SingleOrDefault(); + Assert.NotNull(completedEvent); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningContent_EmitsReasoningEventsAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new TextReasoningContent("Let me think about this...")] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + // Should have: ReasoningAdded, SummaryPartAdded, TextDelta, TextDone, SummaryDone, ReasoningDone, Completed + Assert.True(events.Count >= 5, $"Expected at least 5 events for reasoning, got {events.Count}"); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_CancellationRequested_ThrowsAsync() + { + var (stream, _) = CreateTestStream(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var updates = ToAsync(new[] { new AgentResponseUpdate { Contents = [new MeaiTextContent("test")] } }); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token)) + { + // Should throw before yielding + } + }); + } + + // F-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyTextContent_NoTextDeltaEmittedAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullTextContent_NoTextDeltaEmittedAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent(null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.DoesNotContain(events, e => e is ResponseTextDeltaEvent); + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // F-07 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DifferentMessageIds_CreatesMultipleMessagesAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // F-08 + [Fact] + public async Task ConvertUpdatesToEventsAsync_NullMessageIds_TreatedAsSameMessageAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("First")] }, + new AgentResponseUpdate { MessageId = null, Contents = [new MeaiTextContent("Second")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + } + + // G-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallClosesOpenMessageAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("thinking...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "test" })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // G-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithNullArguments_EmitsEmptyJsonAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "do_something", null)] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + } + + // G-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallWithEmptyCallId_GeneratesCallIdAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new FunctionCallContent("", "do_something", new Dictionary { ["x"] = 1 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + } + + // G-05 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleFunctionCalls_EmitsSeparateBuildersAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // H-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningWithNullText_EmitsEmptyStringAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new TextReasoningContent(null)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.True(events.Count >= 5, $"Expected at least 5 events, got {events.Count}"); + Assert.IsType(events[^1]); + } + + // H-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningClosesOpenMessageAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { Contents = [new TextReasoningContent("thinking")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // I-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentWithNullMessage_UsesDefaultMessageAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new ErrorContent(null!)] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseFailedEvent); + } + + // I-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorContentClosesOpenMessageAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Something broke")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + } + + // I-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ErrorAfterPartialText_ClosesMessageThenFailsAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial text")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // J-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultipleUsageUpdates_AccumulatesTokensAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Hi")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 10, OutputTokenCount = 5, TotalTokenCount = 15 })] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 20, OutputTokenCount = 10, TotalTokenCount = 30 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // J-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UsageWithZeroTokens_StillCompletesAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 0, OutputTokenCount = 0, TotalTokenCount = 0 })] + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseCompletedEvent); + } + + // K-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_DataContent_IsSkippedWithNoEventsAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new DataContent("data:image/png;base64,aWNv", "image/png")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_UriContent_IsSkippedWithNoEventsAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new UriContent("https://example.com/file.txt", "text/plain")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // K-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionResultContent_IsSkippedWithNoEventsAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "result data")] }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Single(events); + Assert.IsType(events[0]); + } + + // L-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorInvokedEvent_EmitsWorkflowActionItemAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("executor_1", "invoked") }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorCompletedEvent_EmitsCompletedWorkflowActionAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("executor_1", null) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailedEvent_EmitsFailedWorkflowActionAsync() + { + var (stream, _) = CreateTestStream(); + var update = new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("executor_1", new InvalidOperationException("test error")) }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream)) + { + events.Add(evt); + } + + Assert.Contains(events, e => e is ResponseOutputItemAddedEvent); + Assert.Contains(events, e => e is ResponseOutputItemDoneEvent); + Assert.IsType(events[^1]); + } + + // L-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowEventClosesOpenMessageAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("partial")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // L-06 + [Fact] + public async Task ConvertUpdatesToEventsAsync_InterleavedWorkflowAndTextEventsAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("exec_1", "invoked") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Agent says hello")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("exec_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // M-01 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenFunctionCallThenText_ProducesCorrectSequenceAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Let me check...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "search", new Dictionary { ["q"] = "weather" })] }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Here are the results")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // M-02 + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReasoningThenText_ProducesCorrectSequenceAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new TextReasoningContent("Thinking about the answer...")] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(2, events.OfType().Count()); + } + + // M-03 + [Fact] + public async Task ConvertUpdatesToEventsAsync_TextThenError_EmitsMessageThenFailedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting...")] }, + new AgentResponseUpdate { Contents = [new ErrorContent("Unexpected error")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + Assert.Single(events.OfType()); + } + + // M-04 + [Fact] + public async Task ConvertUpdatesToEventsAsync_FunctionCallThenTextThenFunctionCall_ProducesThreeItemsAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_1", "func_a", new Dictionary { ["a"] = 1 })] }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Processing...")] }, + new AgentResponseUpdate { Contents = [new FunctionCallContent("call_2", "func_b", new Dictionary { ["b"] = 2 })] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Equal(3, events.OfType().Count()); + } + + // ===== Workflow content flow tests (W series) ===== + // These simulate the exact update patterns that WorkflowSession.InvokeStageAsync() produces + // when wrapping a Workflow as an AIAgent via AsAIAgent(). + + // W-01: Multi-executor text output — different MessageIds cause separate messages + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiExecutorTextOutput_CreatesSeparateMessagesAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Executor 1 invoked (RawRepresentation) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + // Executor 1 produces text (unwrapped AgentResponseUpdateEvent) + new AgentResponseUpdate { MessageId = "msg_agent1", Contents = [new MeaiTextContent("Hello from agent 1")] }, + // Executor 1 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Executor 2 invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + // Executor 2 produces text (different MessageId) + new AgentResponseUpdate { MessageId = "msg_agent2", Contents = [new MeaiTextContent("Hello from agent 2")] }, + // Executor 2 completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow action items (invoked) + 1 text message + 2 workflow action items (completed) + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + // 2 text deltas (one per agent) + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-02: Workflow error via ErrorContent (as produced by WorkflowSession for WorkflowErrorEvent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowErrorAsContent_EmitsFailedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting work...")] }, + // WorkflowErrorEvent is converted to ErrorContent by WorkflowSession + new AgentResponseUpdate { Contents = [new ErrorContent("Workflow execution failed")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should close the open message, then emit failed + Assert.True(events.OfType().Any()); + Assert.IsType(events[^1]); + Assert.DoesNotContain(events, e => e is ResponseCompletedEvent); + } + + // W-03: Function call from workflow executor (e.g. handoff agent calling transfer_to_agent) + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowFunctionCall_EmitsFunctionCallEventsAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage_agent", "start") }, + // Agent produces function call (handoff) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_handoff", "transfer_to_code_expert", + new Dictionary { ["reason"] = "User asked about code" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("code_expert", "start") }, + new AgentResponseUpdate { MessageId = "msg_expert", Contents = [new MeaiTextContent("Here's how async/await works...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("code_expert", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Should have: 4 workflow actions + 1 function call + 1 text message = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-04: Informational events (superstep, workflow started) are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_InformationalWorkflowEvents_AreSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Result")] }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only one output item (the text message), no workflow action items for informational events + Assert.Single(events.OfType()); + Assert.Contains(events, e => e is ResponseTextDeltaEvent); + Assert.IsType(events[^1]); + } + + // W-05: Warning events are silently skipped + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowWarningEvent_IsSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowWarningEvent("Agent took too long") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Done")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-06: Streaming text from multiple workflow turns (same executor, different message IDs) + [Fact] + public async Task ConvertUpdatesToEventsAsync_MultiTurnSameExecutor_CreatesSeparateMessagesAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn1", Contents = [new MeaiTextContent("First response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + // Same executor invoked again (second superstep) + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_turn2", Contents = [new MeaiTextContent("Second response")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + } + + // W-07: Executor failure mid-stream with partial text + [Fact] + public async Task ConvertUpdatesToEventsAsync_ExecutorFailureAfterPartialText_ClosesMessageAndEmitsFailureAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Starting to process...")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorFailedEvent("agent_1", new InvalidOperationException("Agent crashed")) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Text message should be closed before the failed workflow action item + Assert.True(events.OfType().Any()); + // Workflow action items: invoked + failed = 2, plus text message = 3 + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-08: Full handoff pattern — triage → function call → target agent text + [Fact] + public async Task ConvertUpdatesToEventsAsync_FullHandoffPattern_ProducesCorrectEventSequenceAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Workflow lifecycle + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("triage", "start") }, + // Triage agent decides to hand off + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_1", "transfer_to_expert", + new Dictionary { ["reason"] = "technical question" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("triage", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Next superstep + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("expert", "start") }, + // Expert agent responds with text + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent("Let me explain...")] }, + new AgentResponseUpdate { MessageId = "msg_expert_1", Contents = [new MeaiTextContent(" Here's how it works.")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("expert", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: invoked triage, completed triage, invoked expert, completed expert = 4 + // Content items: 1 function call, 1 text message = 2 + // Total output items: 6 + Assert.Equal(6, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + // Two text deltas for the two streaming chunks + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-09: SubworkflowErrorEvent treated as informational (error content comes separately) + [Fact] + public async Task ConvertUpdatesToEventsAsync_SubworkflowErrorEvent_IsSkippedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SubworkflowErrorEvent("sub_1", new InvalidOperationException("sub failed")) }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Recovered")] }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // SubworkflowErrorEvent extends WorkflowErrorEvent which falls through to default skip + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + // W-10: Mixed content types from workflow — reasoning + text + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowReasoningThenText_ProducesCorrectSequenceAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("thinking_agent", "start") }, + // Agent produces reasoning content + new AgentResponseUpdate { Contents = [new TextReasoningContent("Analyzing the problem...")] }, + // Then text response + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("The answer is 42")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("thinking_agent", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 2 (invoked + completed), reasoning: 1, text message: 1 = 4 output items + Assert.Equal(4, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // W-11: Usage content accumulated across workflow executors + [Fact] + public async Task ConvertUpdatesToEventsAsync_WorkflowUsageAcrossExecutors_AccumulatesCorrectlyAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_1", Contents = [new MeaiTextContent("Response 1")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 100, OutputTokenCount = 50, TotalTokenCount = 150 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_2", Contents = [new MeaiTextContent("Response 2")] }, + new AgentResponseUpdate { Contents = [new UsageContent(new UsageDetails { InputTokenCount = 200, OutputTokenCount = 100, TotalTokenCount = 300 })] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Usage should be accumulated in the completed event + Assert.IsType(events[^1]); + } + + // W-12: Empty workflow — only lifecycle events, no content + [Fact] + public async Task ConvertUpdatesToEventsAsync_EmptyWorkflowOnlyLifecycle_EmitsOnlyCompletedAsync() + { + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("start") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Only the terminal completed event + Assert.Single(events); + Assert.IsType(events[0]); + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..aadca65643 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using Azure.AI.AgentServer.Responses; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddFoundryResponses_RegistersResponseHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddFoundryResponses(); + + var descriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(descriptor); + Assert.Equal(typeof(AgentFrameworkResponseHandler), descriptor.ImplementationType); + } + + [Fact] + public void AddFoundryResponses_CalledTwice_RegistersOnce() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddFoundryResponses(); + services.AddFoundryResponses(); + + var count = services.Count(d => d.ServiceType == typeof(ResponseHandler)); + Assert.Equal(1, count); + } + + [Fact] + public void AddFoundryResponses_NullServices_ThrowsArgumentNullException() + { + Assert.Throws( + () => FoundryHostingExtensions.AddFoundryResponses(null!)); + } + + [Fact] + public void AddFoundryResponses_WithAgent_RegistersAgentAndHandler() + { + var services = new ServiceCollection(); + services.AddLogging(); + var mockAgent = new Mock(); + + services.AddFoundryResponses(mockAgent.Object); + + var handlerDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(ResponseHandler)); + Assert.NotNull(handlerDescriptor); + + var agentDescriptor = services.FirstOrDefault( + d => d.ServiceType == typeof(AIAgent)); + Assert.NotNull(agentDescriptor); + } + + [Fact] + public void AddFoundryResponses_WithNullAgent_ThrowsArgumentNullException() + { + var services = new ServiceCollection(); + Assert.Throws( + () => services.AddFoundryResponses(null!)); + } + + [Fact] + public void ApplyOpenTelemetry_NonInstrumentedAgent_WrapsWithOpenTelemetryAgent() + { + var mockAgent = new Mock(); + + var result = FoundryHostingExtensions.ApplyOpenTelemetry(mockAgent.Object); + + Assert.NotNull(result.GetService()); + } + + [Fact] + public void ApplyOpenTelemetry_AlreadyInstrumentedAgent_ReturnsSameReference() + { + var mockAgent = new Mock(); + var instrumented = mockAgent.Object.AsBuilder() + .UseOpenTelemetry() + .Build(); + + var result = FoundryHostingExtensions.ApplyOpenTelemetry(instrumented); + + Assert.Same(instrumented, result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs new file mode 100644 index 0000000000..008e3f3347 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/UserAgentMiddlewareTests.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Tests for the AgentFrameworkUserAgentMiddleware registered by +/// . +/// +public sealed partial class UserAgentMiddlewareTests : IAsyncDisposable +{ + private const string VersionedUserAgentPattern = @"agent-framework-dotnet/\d+\.\d+\.\d+(-[\w.]+)?"; + + private WebApplication? _app; + private HttpClient? _httpClient; + + public async ValueTask DisposeAsync() + { + this._httpClient?.Dispose(); + if (this._app != null) + { + await this._app.DisposeAsync(); + } + } + + [Fact] + public async Task MapFoundryResponses_NoUserAgentHeader_SetsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_WithExistingUserAgent_AppendsAgentFrameworkUserAgentAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + request.Headers.TryAddWithoutValidation("User-Agent", "MyApp/1.0"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.StartsWith("MyApp/1.0", userAgent); + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + [Fact] + public async Task MapFoundryResponses_AlreadyContainsUserAgent_DoesNotDuplicateAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + // First request to capture the actual middleware-generated value + using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + var firstResponse = await this._httpClient!.SendAsync(firstRequest); + var middlewareValue = await firstResponse.Content.ReadAsStringAsync(); + + // Act: send a second request that already contains the middleware value + using var secondRequest = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + secondRequest.Headers.TryAddWithoutValidation("User-Agent", $"MyApp/2.0 {middlewareValue}"); + var secondResponse = await this._httpClient!.SendAsync(secondRequest); + var userAgent = await secondResponse.Content.ReadAsStringAsync(); + + // Assert: should remain unchanged (no duplication) + Assert.Equal($"MyApp/2.0 {middlewareValue}", userAgent); + Assert.Single(VersionedUserAgentRegex().Matches(userAgent)); + } + + [Fact] + public async Task MapFoundryResponses_UserAgentValue_ContainsVersionAsync() + { + // Arrange + await this.CreateTestServerAsync(); + + using var request = new HttpRequestMessage(HttpMethod.Get, "/test-ua"); + + // Act + var response = await this._httpClient!.SendAsync(request); + var userAgent = await response.Content.ReadAsStringAsync(); + + // Assert: should match "agent-framework-dotnet/x.y.z" pattern + Assert.Matches(VersionedUserAgentPattern, userAgent); + } + + private async Task CreateTestServerAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + var mockAgent = new Mock(); + builder.Services.AddFoundryResponses(mockAgent.Object); + + this._app = builder.Build(); + this._app.MapFoundryResponses(); + + // Test endpoint that echoes the User-Agent header after middleware processing + this._app.MapGet("/test-ua", (HttpContext ctx) => + Results.Text(ctx.Request.Headers.UserAgent.ToString())); + + await this._app.StartAsync(); + + var testServer = this._app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found"); + + this._httpClient = testServer.CreateClient(); + } + + [GeneratedRegex(VersionedUserAgentPattern)] + private static partial Regex VersionedUserAgentRegex(); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs new file mode 100644 index 0000000000..05be11c3d8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using MeaiTextContent = Microsoft.Extensions.AI.TextContent; + +namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting; + +/// +/// Integration tests that verify workflow execution through the +/// pipeline. +/// These use real workflow builders and the InProcessExecution environment +/// to produce authentic streaming event patterns. +/// +public class WorkflowIntegrationTests +{ + // ===== Sequential Workflow Tests ===== + + [Fact] + public async Task SequentialWorkflow_SingleAgent_ProducesTextOutputAsync() + { + // Arrange: single-agent sequential workflow + var echoAgent = new StreamingTextAgent("echo", "Hello from the workflow!"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential", echoAgent); + var workflowAgent = workflow.AsAIAgent( + id: "workflow-agent", + name: "Test Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + at least one text output + terminal + Assert.IsType(events[0]); + Assert.IsType(events[1]); + Assert.True(events.Count >= 4, $"Expected at least 4 events, got {events.Count}"); + + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + [Fact] + public async Task SequentialWorkflow_TwoAgents_ProducesOutputFromBothAsync() + { + // Arrange: two agents in sequence + var agent1 = new StreamingTextAgent("agent1", "First agent says hello"); + var agent2 = new StreamingTextAgent("agent2", "Second agent says goodbye"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-sequential-2", agent1, agent2); + var workflowAgent = workflow.AsAIAgent( + id: "seq-workflow", + name: "Sequential Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Process this"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have workflow action events for executor lifecycle + var lastEvent = events[^1]; + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + + // Should have output item events (either text messages or workflow actions) + Assert.True(events.OfType().Any(), + "Expected at least one output item from the workflow"); + } + + // ===== Workflow Error Propagation ===== + + [Fact] + public async Task Workflow_AgentThrowsException_ProducesErrorOutputAsync() + { + // Arrange: workflow with an agent that throws + var throwingAgent = new ThrowingStreamingAgent("thrower", new InvalidOperationException("Agent crashed")); + var workflow = AgentWorkflowBuilder.BuildSequential("test-error", throwingAgent); + var workflowAgent = workflow.AsAIAgent( + id: "error-workflow", + name: "Error Workflow", + executionEnvironment: InProcessExecution.OffThread, + includeExceptionDetails: true); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Trigger error"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: should have lifecycle events + error/failure indicator + Assert.IsType(events[0]); + Assert.IsType(events[1]); + + var lastEvent = events[^1]; + // Workflow errors surface as either Failed or Completed (depending on error handling) + Assert.True( + lastEvent is ResponseCompletedEvent || lastEvent is ResponseFailedEvent, + $"Expected terminal event, got {lastEvent.GetType().Name}"); + } + + // ===== Workflow Action Lifecycle Events ===== + + [Fact] + public async Task Workflow_ExecutorEvents_ProduceWorkflowActionItemsAsync() + { + // Arrange + var agent = new StreamingTextAgent("test-agent", "Result"); + var workflow = AgentWorkflowBuilder.BuildSequential("test-actions", agent); + var workflowAgent = workflow.AsAIAgent( + id: "actions-workflow", + name: "Actions Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var (handler, request, context) = CreateHandlerWithAgent(workflowAgent, "Hello"); + + // Act + var events = await CollectEventsAsync(handler, request, context); + + // Assert: workflow should produce OutputItemAdded events for executor lifecycle + var addedEvents = events.OfType().ToList(); + Assert.True(addedEvents.Count >= 1, + $"Expected at least 1 output item added event, got {addedEvents.Count}"); + } + + // ===== Keyed Workflow Registration ===== + + [Fact] + public async Task WorkflowAgent_RegisteredWithKey_ResolvesCorrectlyAsync() + { + // Arrange: workflow agent registered with a keyed service name + var agent = new StreamingTextAgent("inner", "Keyed workflow response"); + var workflow = AgentWorkflowBuilder.BuildSequential("keyed-wf", agent); + var workflowAgent = workflow.AsAIAgent( + id: "keyed-workflow", + name: "Keyed Workflow", + executionEnvironment: InProcessExecution.OffThread); + + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddKeyedSingleton("my-workflow", workflowAgent); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( + model: "test", + agentReference: new AgentReference("my-workflow")); + request.Input = CreateUserInput("Test keyed workflow"); + var mockContext = CreateMockContext(); + + // Act + var events = await CollectEventsAsync(handler, request, mockContext.Object); + + // Assert + Assert.IsType(events[0]); + Assert.True(events.Count >= 3, $"Expected at least 3 events, got {events.Count}"); + } + + // ===== OutputConverter Direct Workflow Pattern Tests ===== + // These test the OutputConverter directly with update patterns that mirror real workflows. + + [Fact] + public async Task OutputConverter_SequentialWorkflowPattern_ProducesCorrectEventsAsync() + { + // Simulate what WorkflowSession produces for a 2-agent sequential workflow + var (stream, _) = CreateTestStream(); + var updates = new[] + { + // Superstep 1: Agent 1 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_1", "start") }, + new AgentResponseUpdate { MessageId = "msg_a1", Contents = [new MeaiTextContent("Agent 1 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Superstep 2: Agent 2 + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("agent_2", "start") }, + new AgentResponseUpdate { MessageId = "msg_a2", Contents = [new MeaiTextContent("Agent 2 output")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow action items + 2 text messages = 6 output items + Assert.Equal(6, events.OfType().Count()); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_GroupChatPattern_ProducesCorrectEventsAsync() + { + // Simulate round-robin group chat: agent1 → agent2 → agent1 → terminate + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_1", Contents = [new MeaiTextContent("Agent 1 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_2", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_2", Contents = [new MeaiTextContent("Agent 2 turn 1")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_2", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(3) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("chat_agent_1", "turn") }, + new AgentResponseUpdate { MessageId = "msg_gc_3", Contents = [new MeaiTextContent("Agent 1 turn 2")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("chat_agent_1", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(3) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 6 workflow actions + 3 text messages = 9 output items + Assert.Equal(9, events.OfType().Count()); + Assert.Equal(3, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_CodeExecutorPattern_ProducesCorrectEventsAsync() + { + // Simulate a code-based FunctionExecutor: invoked → completed, no text content + // (code executors don't produce AgentResponseUpdateEvent, just executor lifecycle) + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("uppercase_fn", "hello") }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("uppercase_fn", "HELLO") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + // Second executor uses the output + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(2) }, + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("format_agent", "start") }, + new AgentResponseUpdate { MessageId = "msg_fmt", Contents = [new MeaiTextContent("Formatted: HELLO")] }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("format_agent", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(2) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 4 workflow actions + 1 text message = 5 output items + Assert.Equal(5, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_SubworkflowPattern_ProducesCorrectEventsAsync() + { + // Simulate a parent workflow that invokes a sub-workflow executor + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new WorkflowStartedEvent("parent") }, + new AgentResponseUpdate { RawRepresentation = new SuperStepStartedEvent(1) }, + // Sub-workflow executor invoked + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("sub_workflow_host", "start") }, + // Inner agent within sub-workflow produces text (unwrapped by WorkflowSession) + new AgentResponseUpdate { MessageId = "msg_sub_1", Contents = [new MeaiTextContent("Sub-workflow agent output")] }, + // Sub-workflow executor completed + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("sub_workflow_host", null) }, + new AgentResponseUpdate { RawRepresentation = new SuperStepCompletedEvent(1) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // 2 workflow actions + 1 text message = 3 output items + Assert.Equal(3, events.OfType().Count()); + Assert.Single(events.OfType()); + Assert.IsType(events[^1]); + } + + [Fact] + public async Task OutputConverter_WorkflowWithMultipleContentTypes_HandlesAllCorrectlyAsync() + { + // Simulate a workflow producing reasoning, text, function calls, and usage + var (stream, _) = CreateTestStream(); + var updates = new[] + { + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("planner", "start") }, + // Reasoning + new AgentResponseUpdate { Contents = [new TextReasoningContent("Let me think about this...")] }, + // Function call (tool use) + new AgentResponseUpdate + { + Contents = [new FunctionCallContent("call_search", "web_search", + new Dictionary { ["query"] = "latest news" })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("planner", null) }, + // Next executor uses tool result + new AgentResponseUpdate { RawRepresentation = new ExecutorInvokedEvent("writer", "start") }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("Based on my research, ")] }, + new AgentResponseUpdate { MessageId = "msg_w1", Contents = [new MeaiTextContent("here are the findings.")] }, + new AgentResponseUpdate + { + Contents = [new UsageContent(new UsageDetails { InputTokenCount = 500, OutputTokenCount = 200, TotalTokenCount = 700 })] + }, + new AgentResponseUpdate { RawRepresentation = new ExecutorCompletedEvent("writer", null) }, + }; + + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(updates), stream)) + { + events.Add(evt); + } + + // Workflow actions: 4 (2 invoked + 2 completed) + // Content: 1 reasoning + 1 function call + 1 text message = 3 + // Total: 7 output items + Assert.Equal(7, events.OfType().Count()); + Assert.Contains(events, e => e is ResponseFunctionCallArgumentsDoneEvent); + Assert.Equal(2, events.OfType().Count()); + Assert.IsType(events[^1]); + } + + // ===== Helpers ===== + + private static (AgentFrameworkResponseHandler handler, CreateResponse request, ResponseContext context) + CreateHandlerWithAgent(AIAgent agent, string userMessage) + { + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + services.AddSingleton>(NullLogger.Instance); + var sp = services.BuildServiceProvider(); + + var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + request.Input = CreateUserInput(userMessage); + var mockContext = CreateMockContext(); + + return (handler, request, mockContext.Object); + } + + private static BinaryData CreateUserInput(string text) + { + return BinaryData.FromObjectAsJson(new[] + { + new { type = "message", id = "msg_in_1", status = "completed", role = "user", + content = new[] { new { type = "input_text", text } } + } + }); + } + + private static Mock CreateMockContext() + { + var mock = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + mock.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + mock.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + return mock; + } + + private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() + { + var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; + var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var stream = new ResponseEventStream(mockContext.Object, request); + return (stream, mockContext); + } + + private static async Task> CollectEventsAsync( + AgentFrameworkResponseHandler handler, + CreateResponse request, + ResponseContext context) + { + var events = new List(); + await foreach (var evt in handler.CreateAsync(request, context, CancellationToken.None)) + { + events.Add(evt); + } + + return events; + } + + private static async IAsyncEnumerable ToAsync(IEnumerable source) + { + foreach (var item in source) + { + yield return item; + } + + await Task.CompletedTask; + } + + // ===== Test Agent Types ===== + + /// + /// A test agent that streams a single text update. + /// + private sealed class StreamingTextAgent(string id, string responseText) : AIAgent + { + public new string Id => id; + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + yield return new AgentResponseUpdate + { + MessageId = $"msg_{id}", + Contents = [new MeaiTextContent(responseText)] + }; + + await Task.CompletedTask; + } + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } + + /// + /// A test agent that always throws an exception during streaming. + /// + private sealed class ThrowingStreamingAgent(string id, Exception exception) : AIAgent + { + public new string Id => id; + + protected override IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw exception; + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session, + AgentRunOptions? options, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask CreateSessionCoreAsync( + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask SerializeSessionCoreAsync( + AgentSession session, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override ValueTask DeserializeSessionCoreAsync( + JsonElement serializedState, + JsonSerializerOptions? jsonSerializerOptions, + CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 14e4ed68b4..2cead6029a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -1,14 +1,39 @@ + + false + $(NoWarn);NU1605;NU1903 + + - + + + + + + + + + + + + + + + + + + + + + From bca40a7e905351cadbd02e875e82f8f28d43d218 Mon Sep 17 00:00:00 2001 From: S3rj <31356555+Serjbory@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:29:00 +0200 Subject: [PATCH 21/52] =?UTF-8?q?Python:=20fix:=20exclude=20null=20file=5F?= =?UTF-8?q?id=20from=20input=5Fimage=20payload=20to=20prevent=20400=20sch?= =?UTF-8?q?=E2=80=A6=20(#5125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: exclude null file_id from input_image payload to prevent 400 schema error (#5120) * test: add case for additional_properties present without file_id key --------- Co-authored-by: Sergey Borisov --- .../openai/agent_framework_openai/_chat_client.py | 2 +- .../openai/tests/openai/test_openai_chat_client.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 5b7584dc6d..9da2ab354e 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -1405,7 +1405,7 @@ class RawOpenAIChatClient( # type: ignore[misc] else "auto", } file_id = content.additional_properties.get("file_id") if content.additional_properties else None - if file_id: + if file_id is not None: result["file_id"] = file_id return result if content.has_top_level_media_type("audio"): diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index 8c956a6339..e6b93b19c3 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2975,6 +2975,17 @@ def test_prepare_content_for_openai_image_content() -> None: assert result["detail"] == "auto" assert "file_id" not in result + # Test image content with additional_properties present but no file_id key + image_content_detail_only = Content.from_uri( + uri="https://example.com/basic.png", + media_type="image/png", + additional_properties={"detail": "high"}, + ) + result = client._prepare_content_for_openai("user", image_content_detail_only) + assert result["type"] == "input_image" + assert result["detail"] == "high" + assert "file_id" not in result + def test_prepare_content_for_openai_audio_content() -> None: """Test _prepare_content_for_openai with audio content variations.""" From 9e915b36b6604bde2e7faf7e2bf36036af7ea025 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:52:42 +0900 Subject: [PATCH 22/52] Add pr review GH workflow (#5418) * Add workflow PR review * Allow reviews on draft PRs * Update .github/workflows/devflow-pr-review.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/workflows/devflow-pr-review.yml Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Bump actions/checkout to v6 and uv to 0.11.x --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/devflow-pr-review.yml | 161 ++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .github/workflows/devflow-pr-review.yml diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml new file mode 100644 index 0000000000..61352105d1 --- /dev/null +++ b/.github/workflows/devflow-pr-review.yml @@ -0,0 +1,161 @@ +name: DevFlow PR Review + +on: + pull_request_target: + types: + - opened + - reopened + - ready_for_review + workflow_dispatch: + inputs: + pr_number: + description: Pull request number to review + required: true + type: string + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }} + cancel-in-progress: true + +env: + DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }} + DEVFLOW_REF: main + TARGET_REPO_PATH: ${{ github.workspace }}/target-repo + DEVFLOW_PATH: ${{ github.workspace }}/devflow + +jobs: + team_check: + runs-on: ubuntu-latest + outputs: + is_team_member: ${{ steps.check.outputs.is_team_member }} + pr_number: ${{ steps.pr.outputs.pr_number }} + pr_url: ${{ steps.pr.outputs.pr_url }} + repo: ${{ steps.pr.outputs.repo }} + steps: + - name: Resolve PR metadata + id: pr + shell: bash + env: + PR_HTML_URL: ${{ github.event.pull_request.html_url }} + PR_NUMBER_EVENT: ${{ github.event.pull_request.number }} + PR_NUMBER_INPUT: ${{ inputs.pr_number }} + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then + pr_number="${PR_NUMBER_EVENT}" + pr_url="${PR_HTML_URL}" + else + pr_number="${PR_NUMBER_INPUT}" + pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}" + fi + + if [[ ! "$pr_number" =~ ^[1-9][0-9]*$ ]]; then + echo "Could not determine PR number; for workflow_dispatch runs, the 'pr_number' input is required when not running on pull_request_target." >&2 + exit 1 + fi + + echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT" + echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" + echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT" + + - name: Check PR author team membership + id: check + uses: actions/github-script@v8 + env: + TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + with: + github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + script: | + let author = context.payload.pull_request?.user?.login; + if (!author) { + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: Number(process.env.PR_NUMBER), + }); + author = pr.user.login; + } + + let isTeamMember = false; + try { + const teamMembership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: process.env.TEAM_NAME, + username: author, + }); + isTeamMember = teamMembership.data.state === 'active'; + } catch (error) { + console.log(`Team membership lookup failed for ${author}: ${error.message}`); + isTeamMember = false; + } + + core.setOutput('is_team_member', isTeamMember ? 'true' : 'false'); + if (isTeamMember) { + core.info(`Author ${author} is a team member; proceeding with review.`); + } else { + core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`); + } + + review: + runs-on: ubuntu-latest + needs: team_check + if: ${{ needs.team_check.outputs.is_team_member == 'true' }} + timeout-minutes: 60 + + steps: + # Safe checkout: base repo only, not the untrusted PR head. + - name: Checkout target repo base + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.base.sha || github.sha }} + fetch-depth: 0 + persist-credentials: false + path: target-repo + + # Private DevFlow checkout: the PAT/token grants access to this repo's code. + - name: Checkout DevFlow + uses: actions/checkout@v6 + with: + repository: ${{ env.DEVFLOW_REPOSITORY }} + ref: ${{ env.DEVFLOW_REF }} + token: ${{ secrets.DEVFLOW_TOKEN }} + fetch-depth: 1 + persist-credentials: false + path: devflow + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.11.x" + enable-cache: true + + - name: Install DevFlow dependencies + working-directory: ${{ env.DEVFLOW_PATH }} + run: uv sync --frozen + + - name: Run PR review + id: review + working-directory: ${{ env.DEVFLOW_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }} + SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + PR_URL: ${{ needs.team_check.outputs.pr_url }} + run: | + uv run python scripts/trigger_pr_review.py \ + --pr-url "$PR_URL" \ + --github-username "$GITHUB_ACTOR" \ + --no-require-comment-selection From ea3320d39f17505a026656732f43a0373afcb826 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:19:31 +0900 Subject: [PATCH 23/52] Python: Fix OpenAI Responses streaming to propagate `created_at` from final `response.completed` event (#5382) * Fix streaming response losing created_at from response.completed event (#5347) The streaming path in _parse_chunk_from_openai did not extract created_at from the response.completed event, unlike the non-streaming path in _parse_responses_response. This caused durabletask persistence warnings when created_at was None. Extract created_at in the response.completed case and pass it to the returned ChatResponseUpdate. Also fix pre-existing pyright errors for optional orjson import in sample files. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix orjson import suppression to use pyright instead of mypy (#5347) Replace `# type: ignore[import-not-found]` with `# pyright: ignore[reportMissingImports]` on optional orjson imports in conversation sample files, matching the repo's Pyright strict configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_openai/_chat_client.py | 5 ++++ .../tests/openai/test_openai_chat_client.py | 24 +++++++++++++++++++ .../conversations/file_history_provider.py | 2 +- ...story_provider_conversation_persistence.py | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/python/packages/openai/agent_framework_openai/_chat_client.py b/python/packages/openai/agent_framework_openai/_chat_client.py index 9da2ab354e..e4f7fa8025 100644 --- a/python/packages/openai/agent_framework_openai/_chat_client.py +++ b/python/packages/openai/agent_framework_openai/_chat_client.py @@ -2028,6 +2028,7 @@ class RawOpenAIChatClient( # type: ignore[misc] local_shell_tool_name = self._get_local_shell_tool_name(options.get("tools")) conversation_id: str | None = None response_id: str | None = None + created_at: str | None = None continuation_token: OpenAIContinuationToken | None = None model = self.model match event.type: @@ -2209,6 +2210,9 @@ class RawOpenAIChatClient( # type: ignore[misc] response_id = event.response.id conversation_id = self._get_conversation_id(event.response, options.get("store")) model = event.response.model + created_at = datetime.fromtimestamp(event.response.created_at, tz=timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ) if event.response.usage: usage = self._parse_usage_from_openai(event.response.usage) if usage: @@ -2589,6 +2593,7 @@ class RawOpenAIChatClient( # type: ignore[misc] response_id=response_id, role="assistant", model=model, + created_at=created_at, continuation_token=continuation_token, additional_properties=metadata, raw_representation=event, diff --git a/python/packages/openai/tests/openai/test_openai_chat_client.py b/python/packages/openai/tests/openai/test_openai_chat_client.py index e6b93b19c3..9bdb1a88c8 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client.py @@ -2192,6 +2192,7 @@ def test_streaming_chunk_with_usage_only() -> None: mock_event.response.id = "resp_usage" mock_event.response.model = "test-model" mock_event.response.conversation = None + mock_event.response.created_at = 1000000000.0 mock_event.response.usage = MagicMock() mock_event.response.usage.input_tokens = 50 mock_event.response.usage.output_tokens = 25 @@ -4449,6 +4450,7 @@ def test_streaming_response_completed_no_continuation_token() -> None: mock_event.response.conversation = MagicMock() mock_event.response.conversation.id = "conv_done" mock_event.response.model = "test-model" + mock_event.response.created_at = 1000000000.0 mock_event.response.usage = None update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) @@ -4456,6 +4458,28 @@ def test_streaming_response_completed_no_continuation_token() -> None: assert update.continuation_token is None +def test_streaming_response_completed_sets_created_at() -> None: + """Test that response.completed sets created_at on the ChatResponseUpdate.""" + client = OpenAIChatClient(model="test-model", api_key="test-key") + chat_options: dict[str, Any] = {} + function_call_ids: dict[int, tuple[str, str]] = {} + + mock_event = MagicMock() + mock_event.type = "response.completed" + mock_event.response = MagicMock() + mock_event.response.id = "resp_created" + mock_event.response.conversation = MagicMock() + mock_event.response.conversation.id = "conv_created" + mock_event.response.model = "test-model" + mock_event.response.created_at = 1000000000.0 + mock_event.response.usage = None + + update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids) + + assert update.created_at is not None + assert update.created_at == "2001-09-09T01:46:40.000000Z" + + def test_map_chat_to_agent_update_preserves_continuation_token() -> None: """Test that map_chat_to_agent_update propagates continuation_token.""" from agent_framework._types import map_chat_to_agent_update diff --git a/python/samples/02-agents/conversations/file_history_provider.py b/python/samples/02-agents/conversations/file_history_provider.py index 04a87f8224..20735ffd17 100644 --- a/python/samples/02-agents/conversations/file_history_provider.py +++ b/python/samples/02-agents/conversations/file_history_provider.py @@ -21,7 +21,7 @@ from dotenv import load_dotenv from pydantic import Field try: - import orjson + import orjson # pyright: ignore[reportMissingImports] except ImportError: orjson = None diff --git a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py index 70c5d7e8e8..693501b0f9 100644 --- a/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py +++ b/python/samples/02-agents/conversations/file_history_provider_conversation_persistence.py @@ -22,7 +22,7 @@ from dotenv import load_dotenv from pydantic import Field try: - import orjson + import orjson # pyright: ignore[reportMissingImports] except ImportError: orjson = None From fffd0acb3e6374dd65a286acfb179021630627d7 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:43:26 +0900 Subject: [PATCH 24/52] Python: fix(foundry): reconcile toolbox hosted-tool payloads with Responses API (#5414) * fix(foundry): reconcile toolbox hosted-tool payloads with Responses API * docs(foundry): update create_sample_toolbox docstring to reflect all tools created --- .../agent_framework_foundry/_chat_client.py | 12 +- .../foundry/agent_framework_foundry/_tools.py | 50 +++++++-- .../tests/foundry/test_foundry_chat_client.py | 105 ++++++++++++++++++ .../foundry_chat_client_with_toolbox.py | 10 +- .../responses/02_local_tools/main.py | 2 +- 5 files changed, 165 insertions(+), 14 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 7c9eb3a68c..735ba9fb57 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -455,8 +455,18 @@ class RawFoundryChatClient( # type: ignore[misc] Returns: An MCPTool configuration ready to pass to an Agent. + + Raises: + ValueError: If neither ``url`` nor ``project_connection_id`` is supplied + — one is required by the Foundry Responses API. """ - mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs) + if not url and not project_connection_id: + raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.") + + mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs} + if url: + mcp_kwargs["server_url"] = url + mcp = FoundryMCPTool(**mcp_kwargs) if description: mcp["server_description"] = description diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index 3c22872e18..4c5956fcfe 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -133,26 +133,55 @@ def select_toolbox_tools( return selected +def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: + """Fail fast on hosted tool payloads that would always be rejected by the Responses API. + + These mismatches are not injectable defaults — the caller must supply the + missing information — so surfacing a clear error here points at the toolbox + definition instead of letting the API return a generic 400. + """ + tool_type = sanitized.get("type") + if tool_type == "file_search" and not sanitized.get("vector_store_ids"): + raise ValueError( + "'file_search' tool is missing required 'vector_store_ids'. " + "If this came from a Foundry toolbox, update the toolbox definition " + "to include at least one vector store ID." + ) + if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"): + raise ValueError( + "'mcp' tool is missing both 'server_url' and 'project_connection_id'. " + "If this came from a Foundry toolbox, update the toolbox definition " + "to include one of these." + ) + + @experimental(feature_id=ExperimentalFeature.TOOLBOXES) def sanitize_foundry_response_tool(tool_item: Any) -> Any: """Return a Responses-API-safe tool payload for Foundry hosted tools. - Azure AI Projects toolbox reads can currently return hosted tool objects with - extra read-model decoration fields such as top-level ``name`` and - ``description``. Azure AI Foundry rejects at least ``name`` on Responses API - requests with: + Reconciles known mismatches between toolbox reads and the Responses API: - ``Unknown parameter: 'tools[0].name'``. + 1. Toolbox reads can return hosted tool objects decorated with read-model + fields such as top-level ``name`` and ``description``. The Responses API + rejects at least ``name`` with ``Unknown parameter: 'tools[0].name'``. + These fields are stripped from non-function hosted tool payloads. + 2. ``code_interpreter`` tools stored in a toolbox without a ``container`` + field (the Azure SDK treats it as optional) are rejected by the Responses + API with ``Missing required parameter: 'tools[N].container'``. A default + ``{"type": "auto"}`` container is injected when absent. + 3. Hosted tools that are structurally incomplete in ways that cannot be + defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without + either ``server_url`` or ``project_connection_id``) raise ``ValueError`` + with a message that points at the toolbox definition. - We defensively strip these decoration fields for non-function hosted tools so - the round-trip - ``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure - SDK/service behavior is corrected upstream. + These are workarounds until the toolbox/Responses proxy normalizes payloads + server-side. """ if isinstance(tool_item, FoundryMCPTool): sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item)) sanitized.pop("name", None) sanitized.pop("description", None) + _validate_hosted_tool_payload(sanitized) return sanitized if isinstance(tool_item, Mapping): @@ -161,6 +190,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any: sanitized = dict(mapping) sanitized.pop("name", None) sanitized.pop("description", None) + if sanitized.get("type") == "code_interpreter" and "container" not in sanitized: + sanitized["container"] = {"type": "auto"} + _validate_hosted_tool_payload(sanitized) return sanitized return cast(Any, tool_item) diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a7c5beb822..68e7adc6fb 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -607,6 +607,14 @@ def test_get_mcp_tool_with_project_connection_id() -> None: assert tool_config["project_connection_id"] == "conn-123" assert tool_config["allowed_tools"] == ["search_docs"] assert tool_config["server_label"] == "Docs_MCP" + # ``server_url`` should not be fabricated when only a project connection is supplied. + assert "server_url" not in tool_config + + +def test_get_mcp_tool_requires_url_or_project_connection_id() -> None: + """Missing both ``url`` and ``project_connection_id`` is always invalid.""" + with pytest.raises(ValueError, match="url.*project_connection_id"): + FoundryChatClient.get_mcp_tool(name="x") def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None: @@ -655,6 +663,103 @@ def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_int assert "description" not in prepared +def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_dict() -> None: + """Toolbox-returned code_interpreter without a container must get a default injected. + + The Azure SDK treats ``container`` as optional, but the Responses API rejects + ``code_interpreter`` entries without one. The sanitizer backfills ``{"type": "auto"}``. + """ + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = { + "type": "code_interpreter", + "name": "code_interpreter_t6bbtm", + } + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "code_interpreter" + assert prepared["container"] == {"type": "auto"} + assert "name" not in prepared + + +def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_sdk_instance() -> None: + """SDK ``CodeInterpreterTool`` instances without a container must also be backfilled. + + Reproduces the toolbox creation path that calls + ``CodeInterpreterTool(name="code_interpreter")`` without a container. + """ + from azure.ai.projects.models import CodeInterpreterTool + + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + response_tools = client._prepare_tools_for_openai([CodeInterpreterTool(name="code_interpreter")]) + + assert len(response_tools) == 1 + prepared = response_tools[0] + assert prepared["type"] == "code_interpreter" + assert prepared["container"] == {"type": "auto"} + assert "name" not in prepared + + +def test_prepare_tools_for_openai_preserves_existing_code_interpreter_container() -> None: + """An already-populated container must not be overwritten by the sanitizer.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + explicit_container = {"file_ids": ["file_123"], "type": "auto"} + tool = {"type": "code_interpreter", "container": explicit_container} + + response_tools = client._prepare_tools_for_openai([tool]) + + assert response_tools[0]["container"] == explicit_container + + +def test_prepare_tools_for_openai_rejects_file_search_without_vector_store_ids() -> None: + """``file_search`` without ``vector_store_ids`` is always invalid — surface a clear error.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + with pytest.raises(ValueError, match="vector_store_ids"): + client._prepare_tools_for_openai([{"type": "file_search", "name": "fs"}]) + + +def test_prepare_tools_for_openai_rejects_mcp_without_server_destination() -> None: + """``mcp`` with neither ``server_url`` nor ``project_connection_id`` is always invalid.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryMCPTool(server_label="orphan") + + with pytest.raises(ValueError, match="server_url.*project_connection_id"): + client._prepare_tools_for_openai([tool]) + + +def test_prepare_tools_for_openai_accepts_mcp_with_only_project_connection_id() -> None: + """MCP tools backed by a Foundry connection (no ``server_url``) must still pass validation.""" + project_client = MagicMock() + project_client.get_openai_client.return_value = _make_mock_openai_client() + client = FoundryChatClient(project_client=project_client, model="test-model") + + tool = FoundryMCPTool(server_label="githubmcp") + tool["project_connection_id"] = "githubmcp" + + response_tools = client._prepare_tools_for_openai([tool]) + + assert len(response_tools) == 1 + assert response_tools[0]["project_connection_id"] == "githubmcp" + assert "server_url" not in response_tools[0] + + def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None: """All non-function hosted tool payloads should drop top-level read-model names.""" project_client = MagicMock() diff --git a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py index 8a532331ae..2d85c12a4b 100644 --- a/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py +++ b/python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py @@ -42,11 +42,12 @@ def create_sample_toolbox(name: str) -> str: Toolboxes are normally configured in the Foundry portal or a deployment script, not the application itself. This helper exists so the samples can be run end-to-end without first setting a toolbox up by hand — delete any - existing toolbox under ``name``, then create a fresh version containing a - single MCP tool. Returns the created version identifier. + existing toolbox under ``name``, then create a fresh version containing an + MCP tool, a web search tool, and a code interpreter tool. Returns the + created version identifier. """ from azure.ai.projects import AIProjectClient - from azure.ai.projects.models import MCPTool, Tool + from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool from azure.core.exceptions import ResourceNotFoundError with ( @@ -67,6 +68,9 @@ def create_sample_toolbox(name: str) -> str: ) ] + tools.append(WebSearchTool(name="web_search")) + tools.append(CodeInterpreterTool(name="code_interpreter")) + created = project_client.beta.toolboxes.create_version( name=name, description="Toolbox version with MCP require_approval set to 'never'.", diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py index 7cba9b821e..02433bb3ca 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py @@ -3,6 +3,7 @@ import os import subprocess from random import randint +from typing import Annotated from agent_framework import Agent, tool from agent_framework.foundry import FoundryChatClient @@ -10,7 +11,6 @@ from agent_framework_foundry_hosting import ResponsesHostServer from azure.identity import AzureCliCredential from dotenv import load_dotenv from pydantic import Field -from typing import Annotated # Load environment variables from .env file load_dotenv() From 3ae86f098e6e55462110b4c11347fb3e3b0b97b6 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:44:41 +0900 Subject: [PATCH 25/52] Python: Propagate thread_id and forwarded_props through AG-UI to A2A context_id (#5383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Propagate session.service_session_id as A2A context_id When A2AAgent is used behind the AG-UI protocol, the client thread_id is stored in session.service_session_id but was never forwarded as the A2A context_id. This broke session continuity across the AG-UI → A2A boundary. Add an optional context_id keyword argument to _prepare_message_for_a2a() and pass session.service_session_id from run(). The explicit message.additional_properties["context_id"] still takes precedence. Fixes #5345 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add integration tests for session context_id wiring in run() (#5345) - Enhance MockA2AClient.send_message to capture last_message for assertions - Add test_run_passes_session_service_session_id_as_context_id: verifies run() passes session.service_session_id through to A2A message context_id - Add test_run_message_context_id_takes_precedence_over_session: verifies explicit message context_id wins over session fallback - Update _prepare_message_for_a2a docstring to document context_id param and its precedence rules Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5345: Python: [Bug]: Inconvenient passing of context_id / thread_id in A2A/AG-UI implementations --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../a2a/agent_framework_a2a/_agent.py | 16 ++++- python/packages/a2a/tests/test_a2a_agent.py | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index a07be3cf2f..696a160cf6 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -295,7 +295,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): else: if not normalized_messages: raise ValueError("At least one message is required when starting a new task (no continuation_token).") - a2a_message = self._prepare_message_for_a2a(normalized_messages[-1]) + a2a_message = self._prepare_message_for_a2a( + normalized_messages[-1], + context_id=session.service_session_id if session else None, + ) a2a_stream = self.client.send_message(a2a_message) provider_session = session @@ -584,7 +587,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): return AgentResponse.from_updates(updates) return AgentResponse(messages=[], response_id=task.id, raw_representation=task) - def _prepare_message_for_a2a(self, message: Message) -> A2AMessage: + def _prepare_message_for_a2a(self, message: Message, *, context_id: str | None = None) -> A2AMessage: """Prepare a Message for the A2A protocol. Transforms Agent Framework Message objects into A2A protocol Messages by: @@ -593,6 +596,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): - Converting file references (URI/data/hosted_file) to FilePart objects - Preserving metadata and additional properties from the original message - Setting the role to 'user' as framework messages are treated as user input + + Args: + message: The framework Message to convert. + context_id: Optional fallback context identifier (e.g. derived from + ``AgentSession.service_session_id``). When the *message* already + carries a ``context_id`` in its ``additional_properties`` that + value takes precedence; otherwise this fallback is used. """ parts: list[A2APart] = [] if not message.contents: @@ -672,7 +682,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): role=A2ARole("user"), parts=parts, message_id=message.message_id or uuid.uuid4().hex, - context_id=message.additional_properties.get("context_id"), + context_id=message.additional_properties.get("context_id") or context_id, metadata=metadata, ) diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index 484d71e22c..dbbad8a865 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -46,6 +46,7 @@ class MockA2AClient: self.responses: list[Any] = [] self.resubscribe_responses: list[Any] = [] self.get_task_response: Task | None = None + self.last_message: Any = None def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None: """Add a mock Message response.""" @@ -111,6 +112,7 @@ class MockA2AClient: async def send_message(self, message: Any) -> AsyncIterator[Any]: """Mock send_message method that yields responses.""" + self.last_message = message self.call_count += 1 # All queued responses are delivered as a single streaming batch per call. @@ -539,6 +541,37 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None: assert result.metadata == {"trace_id": "trace-456"} +def test_prepare_message_for_a2a_uses_fallback_context_id() -> None: + """Test that context_id kwarg is used when message has no context_id property.""" + + agent = A2AAgent(client=MagicMock(), http_client=None) + + message = Message( + role="user", + contents=[Content.from_text(text="Hello")], + ) + + result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1") + + assert result.context_id == "session-ctx-1" + + +def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None: + """Test that message.additional_properties context_id wins over the fallback.""" + + agent = A2AAgent(client=MagicMock(), http_client=None) + + message = Message( + role="user", + contents=[Content.from_text(text="Hello")], + additional_properties={"context_id": "explicit-ctx"}, + ) + + result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1") + + assert result.context_id == "explicit-ctx" + + def test_parse_contents_from_a2a_with_data_part() -> None: """Test conversion of A2A DataPart.""" @@ -868,6 +901,43 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A # endregion +# region Session context_id Integration Tests + + +@mark.asyncio +async def test_run_passes_session_service_session_id_as_context_id(mock_a2a_client: MockA2AClient) -> None: + """Test that run() wires session.service_session_id to the A2A message context_id.""" + agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None) + mock_a2a_client.add_message_response("msg-ctx", "reply") + + session = AgentSession(service_session_id="svc-session-42") + await agent.run("Hello", session=session) + + assert mock_a2a_client.last_message is not None + assert mock_a2a_client.last_message.context_id == "svc-session-42" + + +@mark.asyncio +async def test_run_message_context_id_takes_precedence_over_session(mock_a2a_client: MockA2AClient) -> None: + """Test that an explicit context_id on the message wins over session.service_session_id.""" + agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None) + mock_a2a_client.add_message_response("msg-ctx2", "reply") + + session = AgentSession(service_session_id="svc-session-42") + message = Message( + role="user", + contents=[Content.from_text(text="Hello")], + additional_properties={"context_id": "explicit-ctx"}, + ) + await agent.run(messages=[message], session=session) + + assert mock_a2a_client.last_message is not None + assert mock_a2a_client.last_message.context_id == "explicit-ctx" + + +# endregion + + # region Context Provider Tests From 0b50455e75b79bd3efe4149c38c3ede549ddf8d8 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:45:25 +0900 Subject: [PATCH 26/52] Python: Pass client thread_id as session_id when constructing AgentSession in AG-UI (#5384) * Pass thread_id as session_id when constructing AgentSession in AG-UI run_agent_stream() was constructing AgentSession without passing the client's thread_id as session_id, causing every request to receive a random UUID. This broke session continuity for HistoryProvider implementations that rely on session_id matching the client's thread_id. Pass session_id=thread_id in both the service-session and non-service code paths so the session identity is consistent with the AG-UI client. Fixes #5357 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add test for service_session with no thread_id edge case (#5357) When use_service_session=True but no thread_id/threadId is in the payload, verify session_id is a generated UUID and service_session_id is None. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 4 +- python/packages/ag-ui/tests/ag_ui/conftest.py | 2 + python/packages/ag-ui/tests/ag_ui/test_run.py | 112 ++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index 330a66dc10..4daef8e76e 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -790,9 +790,9 @@ async def run_agent_stream( # Create session (with service session support) if config.use_service_session: supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId") - session = AgentSession(service_session_id=supplied_thread_id) + session = AgentSession(session_id=thread_id, service_session_id=supplied_thread_id) else: - session = AgentSession() + session = AgentSession(session_id=thread_id) # Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation) base_metadata: dict[str, Any] = { diff --git a/python/packages/ag-ui/tests/ag_ui/conftest.py b/python/packages/ag-ui/tests/ag_ui/conftest.py index 744196dbdf..64ac8e9d66 100644 --- a/python/packages/ag-ui/tests/ag_ui/conftest.py +++ b/python/packages/ag-ui/tests/ag_ui/conftest.py @@ -183,6 +183,7 @@ class StubAgent(SupportsAgentRun): self.client = client or SimpleNamespace(function_invocation_configuration=None) self.messages_received: list[Any] = [] self.tools_received: list[Any] | None = None + self.last_session: AgentSession | None = None @overload def run( @@ -216,6 +217,7 @@ class StubAgent(SupportsAgentRun): async def _stream() -> AsyncIterator[AgentResponseUpdate]: self.messages_received = [] if messages is None else list(messages) # type: ignore[arg-type] + self.last_session = session self.tools_received = kwargs.get("tools") for update in self.updates: yield update diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 392f0cd723..70af4c064a 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1640,3 +1640,115 @@ class TestReasoningInSnapshot: # close: MsgEnd(block2) + End(block2) assert isinstance(close[0], ReasoningMessageEndEvent) assert close[0].message_id == "block2" + + +async def test_session_id_matches_thread_id(): + """Session created by run_agent_stream uses the client thread_id as session_id.""" + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + agent = AgentFrameworkAgent(agent=stub) + + payload = { + "thread_id": "my-thread-123", + "run_id": "run-1", + "messages": [{"role": "user", "content": "Hello"}], + } + + _ = [event async for event in agent.run(payload)] + + assert stub.last_session is not None + assert stub.last_session.session_id == "my-thread-123" + + +async def test_session_id_matches_camel_case_thread_id(): + """Session uses threadId (camelCase) as session_id when snake_case is absent.""" + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + agent = AgentFrameworkAgent(agent=stub) + + payload = { + "threadId": "camel-thread-456", + "run_id": "run-2", + "messages": [{"role": "user", "content": "Hello"}], + } + + _ = [event async for event in agent.run(payload)] + + assert stub.last_session is not None + assert stub.last_session.session_id == "camel-thread-456" + + +async def test_session_id_matches_thread_id_with_service_session(): + """Session uses thread_id as session_id even when use_service_session is enabled.""" + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + agent = AgentFrameworkAgent(agent=stub, use_service_session=True) + + payload = { + "thread_id": "service-thread-789", + "run_id": "run-3", + "messages": [{"role": "user", "content": "Hello"}], + } + + _ = [event async for event in agent.run(payload)] + + assert stub.last_session is not None + assert stub.last_session.session_id == "service-thread-789" + assert stub.last_session.service_session_id == "service-thread-789" + + +async def test_session_id_generated_when_no_thread_id(): + """Session gets a generated UUID as session_id when no thread_id is provided.""" + import uuid + + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + agent = AgentFrameworkAgent(agent=stub) + + payload = { + "run_id": "run-4", + "messages": [{"role": "user", "content": "Hello"}], + } + + _ = [event async for event in agent.run(payload)] + + assert stub.last_session is not None + # Should be a valid UUID (auto-generated) + uuid.UUID(stub.last_session.session_id) + + +async def test_service_session_no_thread_id_generates_uuid(): + """With use_service_session=True and no thread_id, session_id is a UUID and service_session_id is None.""" + import uuid + + from conftest import StubAgent + + from agent_framework_ag_ui import AgentFrameworkAgent + + stub = StubAgent() + agent = AgentFrameworkAgent(agent=stub, use_service_session=True) + + payload = { + "run_id": "run-5", + "messages": [{"role": "user", "content": "Hello"}], + } + + _ = [event async for event in agent.run(payload)] + + assert stub.last_session is not None + # session_id should be a valid auto-generated UUID + uuid.UUID(stub.last_session.session_id) + # service_session_id should be None since no thread_id was supplied + assert stub.last_session.service_session_id is None From d75f874d78cb1795af80149b806f57232d6322b9 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 02:48:02 +0900 Subject: [PATCH 27/52] Adjust dev status for alpha from 4 to 3 in foundry hosting pyproject.toml (#5387) --- python/packages/foundry_hosting/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index fbee988947..94fcf953c2 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta urls.issues = "https://github.com/microsoft/agent-framework/issues" classifiers = [ "License :: OSI Approved :: MIT License", - "Development Status :: 4 - Alpha", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", From 3f23e1dfbfa8d3fbb745782ed8bd5db0277757de Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:16:50 -0700 Subject: [PATCH 28/52] Python: Flaky test report (#5342) * Add flaky test trend reporting to CI workflows Parse JUnit XML (pytest.xml) from each integration test job and aggregate results into a markdown trend report showing per-test pass/fail/skip status across the last 5 runs. Changes: - Add python/scripts/flaky_report/ package (JUnit XML parser + trend report generator following the sample_validation pattern) - Add upload-artifact steps to all 6 integration test jobs in both python-merge-tests.yml and python-integration-tests.yml - Add python-flaky-test-report aggregation job with history caching - Add --junitxml=pytest.xml to integration-tests.yml jobs (already present in merge-tests.yml) - Fix Cosmos job --junitxml path (use absolute path since uv run --directory changes cwd) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix flaky report: handle missing test results gracefully - Guard against missing reports directory in load_current_run() - Only run report job when at least one integration test job completed (skip when all jobs are skipped, e.g. on pull_request events) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: fix provider names and if-expression precedence - Use explicit provider name mapping in _derive_provider() so OpenAI renders correctly instead of 'Openai' - Fix operator precedence in workflow if-expressions by wrapping success/failure checks in parentheses Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add File column and xfail detection to flaky test report - Add File column showing module name (e.g., test_openai_chat_client) to disambiguate tests with the same function name across files - Detect pytest xfail tests in JUnit XML (type=pytest.xfail) and show them with a distinct warning emoji instead of skip emoji - Update legend to include xfail explanation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Foundry embedding env vars to merge-tests workflow Sync the Foundry integration job in python-merge-tests.yml with python-integration-tests.yml by adding FOUNDRY_MODELS_ENDPOINT, FOUNDRY_MODELS_API_KEY, FOUNDRY_EMBEDDING_MODEL, and FOUNDRY_IMAGE_EMBEDDING_MODEL. Once the repo variables/secrets are configured, the embedding integration test will run in CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix File column showing class name instead of module name When a test is inside a class, pytest writes the classname as e.g. 'pkg.test_file.TestClass'. The previous rsplit logic extracted 'TestClass' instead of 'test_file'. Now detect uppercase-starting segments as class names and use the preceding segment instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review: UTC timestamps, XML error handling, summary fix, docstring - Use datetime.now(timezone.utc) for accurate UTC timestamps - Catch ET.ParseError per-file so corrupt XML doesn't crash the report - Remove separate 'error' key from summary (errors folded into 'failed') - Fix _short_name docstring to show actual dotted classname::name format Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/python-integration-tests.yml | 115 ++++- .github/workflows/python-merge-tests.yml | 111 ++++- python/scripts/flaky_report/__init__.py | 11 + python/scripts/flaky_report/__main__.py | 20 + python/scripts/flaky_report/aggregate.py | 396 ++++++++++++++++++ 5 files changed, 651 insertions(+), 2 deletions(-) create mode 100644 python/scripts/flaky_report/__init__.py create mode 100644 python/scripts/flaky_report/__main__.py create mode 100644 python/scripts/flaky_report/aggregate.py diff --git a/.github/workflows/python-integration-tests.yml b/.github/workflows/python-integration-tests.yml index f2fb5c6448..48d15bceda 100644 --- a/.github/workflows/python-integration-tests.yml +++ b/.github/workflows/python-integration-tests.yml @@ -87,6 +87,14 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-openai + path: ./python/pytest.xml + if-no-files-found: ignore # Azure OpenAI integration tests python-tests-azure-openai: @@ -130,6 +138,14 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-azure-openai + path: ./python/pytest.xml + if-no-files-found: ignore # Misc integration tests (Anthropic, Hyperlight, Ollama, MCP) python-tests-misc-integration: @@ -173,6 +189,14 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 30 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-misc + path: ./python/pytest.xml + if-no-files-found: ignore - name: Stop local MCP server if: always() shell: bash @@ -249,6 +273,14 @@ jobs: -x --timeout=360 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-functions + path: ./python/pytest.xml + if-no-files-found: ignore # Foundry integration tests python-tests-foundry: @@ -295,6 +327,14 @@ jobs: -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + --junitxml=pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-foundry + path: ./python/pytest.xml + if-no-files-found: ignore # Azure Cosmos integration tests python-tests-cosmos: @@ -339,7 +379,80 @@ jobs: echo "Cosmos DB emulator did not become ready in time." >&2 exit 1 - name: Test with pytest (Cosmos integration) - run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 + run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-cosmos + path: ./python/pytest.xml + if-no-files-found: ignore + + # Flaky test trend report (aggregates per-job JUnit XML results) + python-flaky-test-report: + name: Flaky Test Report + if: > + always() && + (contains(join(needs.*.result, ','), 'success') || + contains(join(needs.*.result, ','), 'failure')) + needs: + [ + python-tests-openai, + python-tests-azure-openai, + python-tests-misc-integration, + python-tests-functions, + python-tests-foundry, + python-tests-cosmos, + ] + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ inputs.checkout-ref }} + persist-credentials: false + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Download all test results from current run + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: test-results/ + - name: Restore flaky report history cache + uses: actions/cache/restore@v4 + with: + path: python/flaky-report-history.json + key: flaky-report-history-integration-${{ github.run_id }} + restore-keys: | + flaky-report-history-integration- + - name: Generate trend report + run: > + uv run python scripts/flaky_report/aggregate.py + ../test-results/ + flaky-report-history.json + flaky-test-report.md + - name: Post to Job Summary + if: always() + run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY + - name: Save flaky report history cache + if: always() + uses: actions/cache/save@v4 + with: + path: python/flaky-report-history.json + key: flaky-report-history-integration-${{ github.run_id }} + - name: Upload unified trend report + if: always() + uses: actions/upload-artifact@v7 + with: + name: flaky-test-report + path: | + python/flaky-test-report.md + python/flaky-report-history.json python-integration-tests-check: if: always() diff --git a/.github/workflows/python-merge-tests.yml b/.github/workflows/python-merge-tests.yml index dd48b268df..843253e788 100644 --- a/.github/workflows/python-merge-tests.yml +++ b/.github/workflows/python-merge-tests.yml @@ -181,6 +181,13 @@ jobs: display-options: fEX fail-on-empty: false title: OpenAI integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-openai + path: ./python/pytest.xml + if-no-files-found: ignore # Azure OpenAI integration tests python-tests-azure-openai: @@ -244,6 +251,13 @@ jobs: display-options: fEX fail-on-empty: false title: Azure OpenAI integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-azure-openai + path: ./python/pytest.xml + if-no-files-found: ignore # Misc integration tests (Anthropic, Ollama, MCP) python-tests-misc-integration: @@ -321,6 +335,13 @@ jobs: display-options: fEX fail-on-empty: false title: Misc integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-misc + path: ./python/pytest.xml + if-no-files-found: ignore # Azure Functions + Durable Task integration tests python-tests-functions: @@ -392,6 +413,13 @@ jobs: display-options: fEX fail-on-empty: false title: Functions integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-functions + path: ./python/pytest.xml + if-no-files-found: ignore python-tests-foundry: name: Python Integration Tests - Foundry @@ -409,6 +437,10 @@ jobs: FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} + FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }} + FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }} + FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }} + FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }} LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }} defaults: run: @@ -448,6 +480,13 @@ jobs: display-options: fEX fail-on-empty: false title: Test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-foundry + path: ./python/pytest.xml + if-no-files-found: ignore # TODO: Add python-tests-lab @@ -497,7 +536,7 @@ jobs: echo "Cosmos DB emulator did not become ready in time." >&2 exit 1 - name: Test with pytest (Cosmos integration) - run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml + run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml working-directory: ./python - name: Surface failing tests if: always() @@ -508,6 +547,76 @@ jobs: display-options: fEX fail-on-empty: false title: Cosmos integration test results + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: test-results-cosmos + path: ./python/pytest.xml + if-no-files-found: ignore + + # Flaky test trend report (aggregates per-job JUnit XML results) + python-flaky-test-report: + name: Flaky Test Report + if: > + always() && + (contains(join(needs.*.result, ','), 'success') || + contains(join(needs.*.result, ','), 'failure')) + needs: + [ + python-tests-openai, + python-tests-azure-openai, + python-tests-misc-integration, + python-tests-functions, + python-tests-foundry, + python-tests-cosmos, + ] + runs-on: ubuntu-latest + defaults: + run: + working-directory: python + steps: + - uses: actions/checkout@v6 + - name: Set up python and install the project + uses: ./.github/actions/python-setup + with: + python-version: ${{ env.UV_PYTHON }} + os: ${{ runner.os }} + - name: Download all test results from current run + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: test-results/ + - name: Restore flaky report history cache + uses: actions/cache/restore@v4 + with: + path: python/flaky-report-history.json + key: flaky-report-history-merge-${{ github.run_id }} + restore-keys: | + flaky-report-history-merge- + - name: Generate trend report + run: > + uv run python scripts/flaky_report/aggregate.py + ../test-results/ + flaky-report-history.json + flaky-test-report.md + - name: Post to Job Summary + if: always() + run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY + - name: Save flaky report history cache + if: always() + uses: actions/cache/save@v4 + with: + path: python/flaky-report-history.json + key: flaky-report-history-merge-${{ github.run_id }} + - name: Upload unified trend report + if: always() + uses: actions/upload-artifact@v7 + with: + name: flaky-test-report + path: | + python/flaky-test-report.md + python/flaky-report-history.json python-integration-tests-check: if: always() diff --git a/python/scripts/flaky_report/__init__.py b/python/scripts/flaky_report/__init__.py new file mode 100644 index 0000000000..e5a0eeb0ca --- /dev/null +++ b/python/scripts/flaky_report/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Flaky test report aggregation and trend generation. + +Parses JUnit XML (``pytest.xml``) files produced by each CI job, merges +them with historical data, and generates a markdown trend report showing +per-test status across the last N runs. + +Usage: + uv run python -m scripts.flaky_report +""" diff --git a/python/scripts/flaky_report/__main__.py b/python/scripts/flaky_report/__main__.py new file mode 100644 index 0000000000..89969baae6 --- /dev/null +++ b/python/scripts/flaky_report/__main__.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""CLI entry point for the flaky test report tool. + +Usage: + uv run python -m scripts.flaky_report + +Example (from python/ directory): + uv run python -m scripts.flaky_report \\ + ../flaky-reports/ \\ + flaky-report-history.json \\ + flaky-test-report.md +""" + +import sys + +from scripts.flaky_report.aggregate import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/scripts/flaky_report/aggregate.py b/python/scripts/flaky_report/aggregate.py new file mode 100644 index 0000000000..e07a5e136a --- /dev/null +++ b/python/scripts/flaky_report/aggregate.py @@ -0,0 +1,396 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Aggregate per-provider JUnit XML test results and generate a trend report. + +Parses ``pytest.xml`` (JUnit XML) files produced by each CI job, merges them +into a single run, combines with historical data, and generates a markdown +trend table — the same pattern used by ``scripts/sample_validation/aggregate.py``. + +Usage (from CI): + python aggregate.py + +The reports directory is expected to contain subdirectories named +``test-results-/`` each containing a ``pytest.xml`` file +(created by ``actions/download-artifact``). +""" + +from __future__ import annotations + +import json +import sys +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +MAX_HISTORY = 5 + +STATUS_EMOJI = { + "passed": "✅", + "failed": "❌", + "skipped": "⏭️", + "xfailed": "⚠️", + "error": "❌", +} + + +def _format_run_label(timestamp: str) -> str: + """Format a timestamp as a compact column label (e.g. '04-16 00:57').""" + try: + dt = datetime.fromisoformat(timestamp) + return dt.strftime("%m-%d %H:%M") + except (ValueError, TypeError): + return timestamp[:16] + + +def _derive_provider(directory_name: str) -> str: + """Derive a provider label from a report directory name. + + ``test-results-openai`` → ``OpenAI`` + ``test-results-azure-openai`` → ``Azure OpenAI`` + """ + raw = directory_name.replace("test-results-", "") + known = { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "misc": "Misc (Anthropic, Ollama, MCP)", + "functions": "Functions", + "foundry": "Foundry", + "cosmos": "Cosmos", + "unit": "Unit", + } + if raw in known: + return known[raw] + parts = raw.split("-") + return " ".join(p.capitalize() for p in parts) + + +def _parse_junit_xml(xml_path: Path) -> list[dict[str, str]]: + """Parse a JUnit XML file and return a list of test result dicts. + + Each dict has keys: ``nodeid``, ``status``, ``duration``, ``message``. + """ + results: list[dict[str, str]] = [] + try: + tree = ET.parse(xml_path) # noqa: S314 + except ET.ParseError as exc: + print(f"Warning: failed to parse JUnit XML report '{xml_path}': {exc}", file=sys.stderr) + return results + root = tree.getroot() + + # Handle both ... and ... layouts + testcases: list[ET.Element] = [] + if root.tag == "testsuites": + for suite in root.findall("testsuite"): + testcases.extend(suite.findall("testcase")) + elif root.tag == "testsuite": + testcases = list(root.findall("testcase")) + + for tc in testcases: + classname = tc.get("classname", "") + name = tc.get("name", "") + duration = tc.get("time", "0") + + # Use classname::name as a stable identifier. + # pytest writes classname as the dotted module path (possibly including + # a test class), e.g. "packages.openai.tests.openai.test_chat_client" + # or "packages.openai.tests.openai.test_chat_client.TestClass". + nodeid = f"{classname}::{name}" if classname else name + + # Extract module/file name from classname for display context. + # pytest writes classname as a dotted path. For tests inside a class + # it appends the class name, e.g.: + # "packages.foundry.tests.foundry.test_foundry_embedding_client.TestFoundryEmbeddingIntegration" + # We want the file-level module: "test_foundry_embedding_client" + if classname: + parts = classname.rsplit(".", 2) + # If the last segment starts with uppercase it's a class name — take the one before it + if len(parts) >= 2 and parts[-1][0:1].isupper(): + module = parts[-2] + else: + module = parts[-1] + else: + module = "" + + # Determine status from child elements + failure = tc.find("failure") + error = tc.find("error") + skipped = tc.find("skipped") + + if failure is not None: + status = "failed" + message = failure.get("message", "") + elif error is not None: + status = "error" + message = error.get("message", "") + elif skipped is not None: + # pytest marks xfail as + skip_type = skipped.get("type", "") + status = "xfailed" if "xfail" in skip_type else "skipped" + message = skipped.get("message", "") + else: + status = "passed" + message = "" + + results.append({ + "nodeid": nodeid, + "status": status, + "duration": duration, + "message": message, + "module": module, + }) + + return results + + +# --------------------------------------------------------------------------- +# Loading +# --------------------------------------------------------------------------- + + +def load_current_run(reports_dir: Path) -> dict[str, Any]: + """Load per-provider JUnit XML reports from the current CI run and merge. + + Args: + reports_dir: Directory containing ``test-results-/`` subdirs. + + Returns: + Merged run dict with ``timestamp``, ``summary``, ``results``. + """ + combined_results: dict[str, dict[str, str]] = {} # nodeid → {status, provider} + + # actions/download-artifact creates: reports_dir/test-results-openai/pytest.xml + xml_files: list[tuple[str, Path]] = [] + if reports_dir.is_dir(): + for subdir in sorted(reports_dir.iterdir()): + if subdir.is_dir(): + xml_file = subdir / "pytest.xml" + if xml_file.exists(): + xml_files.append((subdir.name, xml_file)) + + if not xml_files: + print(f"Warning: No pytest.xml files found in {reports_dir}") + return { + "timestamp": datetime.now(timezone.utc).isoformat(), + "summary": { + "total": 0, + "passed": 0, + "failed": 0, + "skipped": 0, + }, + "results": {}, + } + + for dir_name, xml_file in xml_files: + print(f" Loading: {xml_file}") + provider = _derive_provider(dir_name) + tests = _parse_junit_xml(xml_file) + for test in tests: + combined_results[test["nodeid"]] = { + "status": test["status"], + "provider": provider, + "module": test.get("module", ""), + } + + # Build summary counts using mutually exclusive status buckets. + # Errors are folded into the failed count for display purposes. + statuses = [r["status"] for r in combined_results.values()] + summary = { + "total": len(statuses), + "passed": statuses.count("passed"), + "failed": statuses.count("failed") + statuses.count("error"), + "skipped": statuses.count("skipped"), + } + + return { + "timestamp": datetime.now(timezone.utc).isoformat(), + "summary": summary, + "results": combined_results, + } + + +def load_history(history_path: Path) -> list[dict[str, Any]]: + """Load previous run history from a cache file.""" + if history_path.exists(): + with open(history_path, encoding="utf-8") as f: + data = json.load(f) + runs = data.get("runs", []) + print(f" Loaded {len(runs)} previous run(s) from history") + return runs + print(" No previous history found") + return [] + + +def save_history(history_path: Path, runs: list[dict[str, Any]]) -> None: + """Save run history, keeping only the last ``MAX_HISTORY`` entries.""" + history_path.parent.mkdir(parents=True, exist_ok=True) + trimmed = runs[-MAX_HISTORY:] + with open(history_path, "w", encoding="utf-8") as f: + json.dump({"runs": trimmed}, f, indent=2) + print(f" Saved {len(trimmed)} run(s) to history") + + +# --------------------------------------------------------------------------- +# Report generation +# --------------------------------------------------------------------------- + + +def _short_name(nodeid: str) -> str: + """Extract a short test name from a full nodeid. + + ``packages.openai.tests.openai.test_openai_chat_client::test_integration_options`` + → ``test_integration_options`` + """ + return nodeid.split("::")[-1] if "::" in nodeid else nodeid + + +def generate_trend_report(runs: list[dict[str, Any]]) -> str: + """Generate a markdown trend report from run history.""" + lines = [ + "# 🔬 Flaky Test Report", + "", + f"*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*", + "", + ] + + # --- Overall status table (most recent first) --- + lines.append("## Overall Status (Last 5 Runs)") + lines.append("") + lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |") + lines.append("|-----|-------|-----------|-----------|------------|") + + for run in reversed(runs): + s = run.get("summary", {}) + total = s.get("total", 0) + label = _format_run_label(run["timestamp"]) + lines.append( + f"| {label} " + f"| {total} " + f"| {s.get('passed', 0)}/{total} " + f"| {s.get('failed', 0)}/{total} " + f"| {s.get('skipped', 0)}/{total} |" + ) + + for _ in range(MAX_HISTORY - len(runs)): + lines.append("| N/A | N/A | N/A | N/A | N/A |") + + lines.append("") + + # --- Per-test results table --- + lines.append("## Per-Test Results") + lines.append("") + + # Collect all test nodeids, providers, and modules across all runs + all_tests: dict[str, str] = {} # nodeid → provider (from most recent run) + all_modules: dict[str, str] = {} # nodeid → module (from most recent run) + for run in runs: + for nodeid, info in run.get("results", {}).items(): + provider = info.get("provider", "Unknown") if isinstance(info, dict) else "Unknown" + module = info.get("module", "") if isinstance(info, dict) else "" + all_tests[nodeid] = provider + all_modules[nodeid] = module + + if not all_tests: + lines.append("*No test results available.*") + return "\n".join(lines) + + # Build header (most recent run first) + header = "| Test | File | Provider |" + separator = "|------|------|----------|" + for run in reversed(runs): + label = _format_run_label(run["timestamp"]) + header += f" {label} |" + separator += "------------|" + for _ in range(MAX_HISTORY - len(runs)): + header += " N/A |" + separator += "-----|" + + lines.append(header) + lines.append(separator) + + # Sort by provider then test name + for nodeid in sorted(all_tests, key=lambda n: (all_tests[n], n)): + provider = all_tests[nodeid] + module = all_modules.get(nodeid, "") + short = _short_name(nodeid) + row = f"| `{short}` | `{module}` | {provider} |" + + for run in reversed(runs): + result = run.get("results", {}).get(nodeid) + if result is None: + emoji = "N/A" + else: + status = result.get("status", "N/A") if isinstance(result, dict) else result + emoji = STATUS_EMOJI.get(status, "❓") + row += f" {emoji} |" + + for _ in range(MAX_HISTORY - len(runs)): + row += " N/A |" + + lines.append(row) + + lines.append("") + lines.append("**Legend:** ✅ Passed · ❌ Failed · ⏭️ Skipped · ⚠️ Expected Failure (xfail) · N/A Not available") + lines.append("") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + + +def main() -> int: + if len(sys.argv) != 4: + print("Usage: python aggregate.py ") + return 1 + + reports_dir = Path(sys.argv[1]) + history_path = Path(sys.argv[2]) + output_path = Path(sys.argv[3]) + + print("Aggregating test results from JUnit XML...") + + # Load current run's per-provider XML reports + print(f"\nLoading reports from {reports_dir}:") + current_run = load_current_run(reports_dir) + s = current_run.get("summary", {}) + total = s.get("total", 0) + print( + f" Current run: {s.get('passed', 0)} passed, " + f"{s.get('failed', 0)} failed, " + f"{s.get('skipped', 0)} skipped " + f"(total: {total})" + ) + + # Load history and append current run (skip empty runs to avoid polluting trend) + print(f"\nLoading history from {history_path}:") + runs = load_history(history_path) + if total > 0: + runs.append(current_run) + runs = runs[-MAX_HISTORY:] + else: + print(" Skipping history append (no test results in current run)") + + # Save updated history + print(f"\nSaving history to {history_path}:") + save_history(history_path, runs) + + # Generate trend report + print("\nGenerating trend report...") + report = generate_trend_report(runs) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(report, encoding="utf-8") + print(f"Trend report written to {output_path}") + + # Print the report to stdout for CI visibility + print("\n" + "=" * 80) + print(report) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From e2f161c8a07479d29587df692db5b05ee73ea88f Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:23:56 +0900 Subject: [PATCH 29/52] Pin to specific release (#5430) --- .github/workflows/devflow-pr-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 61352105d1..9371fa9b89 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -24,7 +24,7 @@ concurrency: env: DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }} - DEVFLOW_REF: main + DEVFLOW_REF: v0.1.15 TARGET_REPO_PATH: ${{ github.workspace }}/target-repo DEVFLOW_PATH: ${{ github.workspace }}/devflow From 5d4873888f6fb9e75f19dfbb9070f637fd9f430d Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:24:21 +0900 Subject: [PATCH 30/52] Don't fail if review issue occurs (#5434) --- .github/workflows/devflow-pr-review.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/devflow-pr-review.yml b/.github/workflows/devflow-pr-review.yml index 9371fa9b89..5ce9592a51 100644 --- a/.github/workflows/devflow-pr-review.yml +++ b/.github/workflows/devflow-pr-review.yml @@ -24,7 +24,7 @@ concurrency: env: DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }} - DEVFLOW_REF: v0.1.15 + DEVFLOW_REF: main TARGET_REPO_PATH: ${{ github.workspace }}/target-repo DEVFLOW_PATH: ${{ github.workspace }}/devflow @@ -108,6 +108,10 @@ jobs: needs: team_check if: ${{ needs.team_check.outputs.is_team_member == 'true' }} timeout-minutes: 60 + # Advisory check: failures here should not block the PR. The reviewer + # posts comments as a best-effort signal; if the pipeline breaks, the + # PR author should still be able to merge without a red required check. + continue-on-error: true steps: # Safe checkout: base repo only, not the untrusted PR head. From acec9caa2fffff00ad489f07724abb8bf3e068ad Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:40:14 +0900 Subject: [PATCH 31/52] Python: Bump Python package versions for a release. (#5432) * Bump Python version for a release. * Revert lockstep bumps on unchanged connectors Per PR review: only connectors that changed (or whose published metadata changed) should get new versions. Keeps released tier at 1.1.1, a2a/ag-ui at 1.0.0b260422, foundry-hosting at 1.0.0a260422; reverts the 19 unchanged betas and 2 unchanged alphas to 1.0.0b260421/1.0.0a260421. Reverts all 26 non-core agent-framework-core floors to >=1.1.0,<2 since no connector actually depends on a 1.1.1 API or bug fix. * Restore lockstep prerelease bumps and raise core floors to >=1.1.1 Reverses the lean-revert: all beta packages stamped 1.0.0b260423 and alpha packages stamped 1.0.0a260423 (Asia date, matching release cut time). All 26 non-core packages raise agent-framework-core lower bound from >=1.1.0,<2 to >=1.1.1,<2 to signal the validated cohort for this release. CHANGELOG date updated to 2026-04-23. --- python/CHANGELOG.md | 18 ++++++ python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry/pyproject.toml | 4 +- .../packages/foundry_hosting/pyproject.toml | 4 +- python/packages/foundry_local/pyproject.toml | 4 +- python/packages/gemini/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- python/packages/hyperlight/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/openai/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 56 +++++++++---------- 30 files changed, 101 insertions(+), 83 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 3579049fc8..f844b45465 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.1] - 2026-04-23 + +### Added +- **agent-framework-core**: Add `expected_output` ground-truth support to `evaluate_workflow` for similarity evaluators ([#5234](https://github.com/microsoft/agent-framework/pull/5234)) +- **agent-framework-ag-ui**, **agent-framework-a2a**: Propagate `thread_id` and `forwarded_props` through AG-UI to A2A `context_id` ([#5383](https://github.com/microsoft/agent-framework/pull/5383)) +- **samples**: Add second approval-required tool (`set_stop_loss`) to `concurrent_builder_tool_approval` sample ([#4875](https://github.com/microsoft/agent-framework/pull/4875)) + +### Changed +- **agent-framework-foundry-hosting**: Correct Development Status classifier from Beta (4) to Alpha (3) to match the package's lifecycle stage ([#5387](https://github.com/microsoft/agent-framework/pull/5387)) +- **tests**: Add Python flaky test report workflow ([#5342](https://github.com/microsoft/agent-framework/pull/5342)) + +### Fixed +- **agent-framework-openai**: Fix OpenAI Responses streaming to propagate `created_at` from the final `response.completed` event ([#5382](https://github.com/microsoft/agent-framework/pull/5382)) +- **agent-framework-openai**: Fix `OpenAIEmbeddingClient` to use `AsyncOpenAI` for `/openai/v1` endpoints ([#5137](https://github.com/microsoft/agent-framework/pull/5137)) +- **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125)) +- **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414)) +- **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384)) + ## [1.1.0] - 2026-04-21 ### Added diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index d21bed0558..cfe1c33da4 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 8eb76406af..0bba0b3006 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index e1f884a751..c82ce7f040 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 63baf70852..d50cf2048a 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index cf55a9d8c5..76a0c50ae4 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index 611329e9e5..eb2239563e 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index 9c46c058a9..acea0c2749 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index c706c6ec22..0662ab25c1 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index b71b662954..6a9069432d 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 2f42515a46..396f3b32e3 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index fec81c2193..d406965cdd 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -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.1.0" +version = "1.1.1" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 6b3d0a9d36..6050f080ec 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 023d4f5d51..fa308dbf55 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index 50d650a21a..b55955d09d 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index d95ed6c13c..58e50d9ed3 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -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.1.0" +version = "1.1.1" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "agent-framework-openai>=1.1.0,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.1.0,<3.0", diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index 94fcf953c2..fcd1e8e21b 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -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.0a260421" +version = "1.0.0a260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "azure-ai-agentserver-core==2.0.0b2", "azure-ai-agentserver-responses==1.0.0b4", "azure-ai-agentserver-invocations==1.0.0b2", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index cf9001ff3a..5dbdcfb15c 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "agent-framework-openai>=1.1.0,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 66cef70fad..2a2d03fe0e 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -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.0a260421" +version = "1.0.0a260423" 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.1.0,<2.0", + "agent-framework-core>=1.1.1,<2.0", "google-genai>=1.0.0,<2.0.0", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 287d5aec86..78364f08a0 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index bf898ce4dd..1b5e650935 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -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.0a260421" +version = "1.0.0a260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "hyperlight-sandbox>=0.3.0,<0.4", "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((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.3.0,<0.4", diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index d1c7f175a1..ed4329ff80 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 7712be304e..52dc028ce0 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index a0f1d6316e..0b0368ba89 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index 2ea7b3eee1..f53258eb98 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -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.1.0" +version = "1.1.1" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index d808388258..58ea3ce9c5 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 257f8df401..baa72d89a3 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 838e7fd968..b9a0c50498 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -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.0b260421" +version = "1.0.0b260423" 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.1.0,<2", + "agent-framework-core>=1.1.1,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index 0d5dac2a0b..6b482161c0 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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.1.0" +version = "1.1.1" 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[all]==1.1.0", + "agent-framework-core[all]==1.1.1", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index 73c18a6375..a1acd778bb 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.1.0" +version = "1.1.1" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -151,7 +151,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -166,7 +166,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -194,7 +194,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -209,7 +209,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -224,7 +224,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -239,7 +239,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -261,7 +261,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -278,7 +278,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -293,7 +293,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -323,7 +323,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.1.0" +version = "1.1.1" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -395,7 +395,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -420,7 +420,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -458,7 +458,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -485,7 +485,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.1.0" +version = "1.1.1" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -504,7 +504,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260421" +version = "1.0.0a260423" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -523,7 +523,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -540,7 +540,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260421" +version = "1.0.0a260423" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -555,7 +555,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -570,7 +570,7 @@ requires-dist = [ [[package]] name = "agent-framework-hyperlight" -version = "1.0.0a260421" +version = "1.0.0a260423" source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -589,7 +589,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -670,7 +670,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -685,7 +685,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -700,7 +700,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.1.0" +version = "1.1.1" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -715,7 +715,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -726,7 +726,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -743,7 +743,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260421" +version = "1.0.0b260423" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From 66e02c10e325b087d62dca0a3575d7f02bb530e7 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:53:00 +0100 Subject: [PATCH 32/52] .NET: [Breaking] Migrate A2A agent and hosting to A2A SDK v1 (#5423) * update a2a agent to the latest a2a sdk (#5257) * Move A2A samples from 04-hosting to 02-agents (#5267) Move the A2A sample projects (A2AAgent_AsFunctionTools and A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to samples/02-agents/A2A/ to better align with the sample directory structure. Update solution file and samples README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix stream reconnection for A2AAgent (#5275) * Add SSE stream reconnection support to A2AAgent Implement automatic reconnection for SSE streams that disconnect mid-task, using the Last-Event-ID header to resume from where the stream left off. Changes: - Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable max retries and delay between attempts - Add new log messages for reconnection events - Add A2AAgent_StreamReconnection sample demonstrating the feature - Update existing polling sample to use simplified SendMessageAsync API - Add unit tests for stream reconnection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments * Address PR review feedback - Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection - Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding - Print update.Text instead of update object in stream reconnection sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use IA2AClientFactory to create A2AClient (#5277) * Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample - Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions - Update A2ACardResolverExtensions to accept IA2AClientFactory - Update A2AClientExtensions to accept IA2AClientFactory - Update A2AAgent to use IA2AClientFactory for client creation - Add A2AAgent_ProtocolSelection sample demonstrating protocol selection - Add comprehensive unit tests for all changes - Update README files with new sample reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reorder params: options before loggerFactory in A2A extensions Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent and GetAIAgentAsync extension methods to follow the repo convention of keeping LoggerFactory and CancellationToken as the last parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Migrate A2A hosting to A2A SDK v1 (#5363) * .NET: Migrate A2A hosting to A2A SDK v1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove unused agent card --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Split A2A endpoint mapping into protocol-specific methods (#5413) * .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive in AgentWebChat.AgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * restore AsyncEnumerable package version * address copilot initial feedback * address automated code review and formatting issues * fix formatting issues * Add DI wiring verification tests for AddA2AServer Add three tests to A2AServerServiceCollectionExtensionsTests that verify custom keyed services are actually wired through to the A2AServer, not just that the server resolves non-null: - Custom IAgentHandler: verifies the keyed handler is invoked when processing a SendMessageRequest instead of the default A2AAgentHandler. - Custom AgentSessionStore (no handler): verifies the keyed session store's GetSessionAsync is called during request processing when no custom handler is registered. - Default stores end-to-end: verifies the InMemoryAgentSessionStore and InMemoryTaskStore defaults successfully process a request. Uses a new CreateAgentMockForRequests helper that includes SerializeSessionCoreAsync setup needed by InMemoryAgentSessionStore. All tests call A2AServer.SendMessageAsync directly (no HTTP layer needed) and use CancellationToken timeouts to guard against hangs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- dotnet/Directory.Packages.props | 10 +- dotnet/agent-framework-dotnet.slnx | 12 +- .../A2AAgent_AsFunctionTools.csproj | 0 .../A2A/A2AAgent_AsFunctionTools/Program.cs | 0 .../A2A/A2AAgent_AsFunctionTools/README.md | 0 .../A2AAgent_PollingForTaskCompletion.csproj | 3 +- .../Program.cs | 6 +- .../README.md | 0 .../A2AAgent_ProtocolSelection.csproj | 19 + .../A2A/A2AAgent_ProtocolSelection/Program.cs | 36 + .../A2A/A2AAgent_ProtocolSelection/README.md | 27 + .../A2AAgent_StreamReconnection.csproj | 23 + .../A2AAgent_StreamReconnection/Program.cs | 55 + .../A2A/A2AAgent_StreamReconnection/README.md | 29 + .../{04-hosting => 02-agents}/A2A/README.md | 4 +- dotnet/samples/02-agents/README.md | 1 + .../A2AClientServer/A2AClient/Program.cs | 10 +- .../A2AServer/HostAgentFactory.cs | 46 +- .../A2AClientServer/A2AServer/Program.cs | 33 +- .../AgentWebChat.AgentHost/Program.cs | 18 +- .../AgentWebChat.Web/A2AAgentClient.cs | 12 +- dotnet/samples/README.md | 2 +- .../src/Microsoft.Agents.AI.A2A/A2AAgent.cs | 281 +++-- .../A2AAgentLogMessages.cs | 13 + .../A2AContinuationToken.cs | 2 +- .../Extensions/A2AAgentCardExtensions.cs | 10 +- .../Extensions/A2ACardResolverExtensions.cs | 8 +- .../Extensions/A2AClientExtensions.cs | 6 +- .../Extensions/ChatMessageExtensions.cs | 6 +- .../Microsoft.Agents.AI.A2A.csproj | 1 + .../A2AEndpointRouteBuilderExtensions.cs | 138 +++ .../EndpointRouteBuilderExtensions.cs | 385 ------- ...ft.Agents.AI.Hosting.A2A.AspNetCore.csproj | 9 +- .../A2AAgentHandler.cs | 192 ++++ .../A2ARunDecisionContext.cs | 8 +- .../A2AServerRegistrationOptions.cs | 30 + .../A2AServerServiceCollectionExtensions.cs | 160 +++ .../AIAgentExtensions.cs | 309 ------ .../AgentRunMode.cs | 13 +- .../Converters/MessageConverter.cs | 12 +- .../A2AAgentTests.cs | 870 ++++++++++++---- .../A2AContinuationTokenTests.cs | 12 + .../Extensions/A2AAIContentExtensionsTests.cs | 24 +- .../Extensions/A2AAgentCardExtensionsTests.cs | 120 ++- .../Extensions/A2AAgentTaskExtensionsTests.cs | 20 +- .../Extensions/A2AArtifactExtensionsTests.cs | 12 +- .../A2ACardResolverExtensionsTests.cs | 58 +- .../Extensions/A2AClientExtensionsTests.cs | 36 + .../Extensions/ChatMessageExtensionsTests.cs | 30 +- .../Microsoft.Agents.AI.A2A.UnitTests.csproj | 4 + .../A2AAgentHandlerTests.cs | 966 ++++++++++++++++++ .../A2AEndpointRouteBuilderExtensionsTests.cs | 559 ++++++++++ .../A2AIntegrationTests.cs | 89 -- ...AServerServiceCollectionExtensionsTests.cs | 459 +++++++++ .../AIAgentExtensionsTests.cs | 866 ---------------- .../AgentRunModeTests.cs | 163 +++ .../Converters/MessageConverterTests.cs | 99 +- .../EndpointRouteA2ABuilderExtensionsTests.cs | 479 --------- 58 files changed, 4201 insertions(+), 2594 deletions(-) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/Program.cs (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_AsFunctionTools/README.md (100%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj (86%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/Program.cs (83%) rename dotnet/samples/{04-hosting => 02-agents}/A2A/A2AAgent_PollingForTaskCompletion/README.md (100%) create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs create mode 100644 dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md rename dotnet/samples/{04-hosting => 02-agents}/A2A/README.md (77%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 46e0d61924..8fedb11f7f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -42,15 +42,15 @@ - + - + - + @@ -104,8 +104,8 @@ - - + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b3a95ea1f6..f1ade4eedc 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -344,11 +344,13 @@ - - - - - + + + + + + + diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/Program.cs diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_AsFunctionTools/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_AsFunctionTools/README.md diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj similarity index 86% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj index 1bccc99d4f..d91b20e34b 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj +++ b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj @@ -2,7 +2,7 @@ Exe - net10.0 + net10.0 enable enable @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs similarity index 83% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs index e1731604a9..9410785c39 100644 --- a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/Program.cs +++ b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/Program.cs @@ -18,8 +18,12 @@ AIAgent agent = agentCard.AsAIAgent(); AgentSession session = await agent.CreateSessionAsync(); +// AllowBackgroundResponses must be true so the server returns immediately with a continuation token +// instead of blocking until the task is complete. +AgentRunOptions options = new() { AllowBackgroundResponses = true }; + // Start the initial run with a long-running task. -AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session); +AgentResponse response = await agent.RunAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session, options: options); // Poll until the response is complete. while (response.ContinuationToken is { } token) diff --git a/dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md similarity index 100% rename from dotnet/samples/04-hosting/A2A/A2AAgent_PollingForTaskCompletion/README.md rename to dotnet/samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/README.md diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj new file mode 100644 index 0000000000..d21ac952b3 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj @@ -0,0 +1,19 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs new file mode 100644 index 0000000000..4d1612ee36 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/Program.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when +// creating an AIAgent from an A2A agent card using A2AClientOptions.PreferredBindings. + +using A2A; +using Microsoft.Agents.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Use A2AClientOptions to explicitly select the HTTP+JSON protocol binding. +// This tells the A2A client factory to prefer the HTTP+JSON interface when the agent card +// advertises multiple supported interfaces. +A2AClientOptions options = new() +{ + PreferredBindings = [ProtocolBindingNames.HttpJson] +}; + +// To prefer JSON-RPC instead, use: +// A2AClientOptions options = new() +// { +// PreferredBindings = [ProtocolBindingNames.JsonRpc] +// }; + +// Create an instance of the AIAgent for an existing A2A agent, using the specified protocol binding. +AIAgent agent = agentCard.AsAIAgent(options: options); + +// Invoke the agent and output the text result. +AgentResponse response = await agent.RunAsync("Tell me a joke about a pirate."); +Console.WriteLine(response); diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md new file mode 100644 index 0000000000..b50a76240c --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_ProtocolSelection/README.md @@ -0,0 +1,27 @@ +# A2A Agent Protocol Selection + +This sample demonstrates how to select the A2A protocol binding when creating an `AIAgent` from an A2A agent card. + +A2A agents can expose multiple interfaces with different protocol bindings (e.g., HTTP+JSON, JSON-RPC). By default, `AsAIAgent()` prefers HTTP+JSON with JSON-RPC as a fallback. This sample shows how to use `A2AClientOptions.PreferredBindings` to explicitly control which protocol binding is used. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Configures `A2AClientOptions` to prefer the HTTP+JSON protocol binding +- Creates an `AIAgent` from the resolved agent card using the specified binding +- Sends a message to the agent and displays the response + +## Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +**Note**: These samples need to be run against a valid A2A server. If no A2A server is available, they can be run against the echo-agent that can be spun up locally by following the guidelines at: https://github.com/a2aproject/a2a-dotnet/blob/main/samples/AgentServer/README.md + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj new file mode 100644 index 0000000000..e75368ea99 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs new file mode 100644 index 0000000000..9a4a680c62 --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/Program.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, +// allowing recovery from stream interruptions without losing progress. + +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var a2aAgentHost = Environment.GetEnvironmentVariable("A2A_AGENT_HOST") ?? throw new InvalidOperationException("A2A_AGENT_HOST is not set."); + +// Initialize an A2ACardResolver to get an A2A agent card. +A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost)); + +// Get the agent card +AgentCard agentCard = await agentCardResolver.GetAgentCardAsync(); + +// Create an instance of the AIAgent for an existing A2A agent specified by the agent card. +AIAgent agent = agentCard.AsAIAgent(); + +AgentSession session = await agent.CreateSessionAsync(); + +ResponseContinuationToken? continuationToken = null; + +await foreach (var update in agent.RunStreamingAsync("Conduct a comprehensive analysis of quantum computing applications in cryptography, including recent breakthroughs, implementation challenges, and future roadmap. Please include diagrams and visual representations to illustrate complex concepts.", session)) +{ + // Saving the continuation token to be able to reconnect to the same response stream later. + // Note: Continuation tokens are only returned for long-running tasks. If the underlying A2A agent + // returns a message instead of a task, the continuation token will not be initialized. + // A2A agents do not support stream resumption from a specific point in the stream, + // but only reconnection to obtain the same response stream from the beginning. + // So, A2A agents will return an initialized continuation token in the first update + // representing the beginning of the stream, and it will be null in all subsequent updates. + if (update.ContinuationToken is { } token) + { + continuationToken = token; + } + + // Imitating stream interruption + break; +} + +// Reconnect to the same response stream using the continuation token obtained from the previous run. +// As a first update, the agent will return an update representing the current state of the response at the moment of calling +// RunStreamingAsync with the same continuation token, followed by other updates until the end of the stream is reached. +if (continuationToken is not null) +{ + await foreach (var update in agent.RunStreamingAsync(session, options: new() { ContinuationToken = continuationToken })) + { + if (!string.IsNullOrEmpty(update.Text)) + { + Console.WriteLine(update.Text); + } + } +} diff --git a/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md new file mode 100644 index 0000000000..ca5b0b66ad --- /dev/null +++ b/dotnet/samples/02-agents/A2A/A2AAgent_StreamReconnection/README.md @@ -0,0 +1,29 @@ +# A2A Agent Stream Reconnection + +This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions without losing progress. + +The sample: + +- Connects to an A2A agent server specified in the `A2A_AGENT_HOST` environment variable +- Sends a request to the agent and begins streaming the response +- Captures a continuation token from the stream for later reconnection +- Simulates a stream interruption by breaking out of the streaming loop +- Reconnects to the same response stream using the captured continuation token +- Displays the response received after reconnection + +This pattern is useful when network interruptions or other failures may disrupt an ongoing streaming response, and you need to recover and continue processing. + +> **Note:** Continuation tokens are only available when the underlying A2A agent returns a task. If the agent returns a message instead, the continuation token will not be initialized and stream reconnection is not applicable. + +# Prerequisites + +Before you begin, ensure you have the following prerequisites: + +- .NET 10.0 SDK or later +- An A2A agent server running and accessible via HTTP + +Set the following environment variable: + +```powershell +$env:A2A_AGENT_HOST="http://localhost:5000" # Replace with your A2A agent server host +``` diff --git a/dotnet/samples/04-hosting/A2A/README.md b/dotnet/samples/02-agents/A2A/README.md similarity index 77% rename from dotnet/samples/04-hosting/A2A/README.md rename to dotnet/samples/02-agents/A2A/README.md index 55539a8322..28f2c0a910 100644 --- a/dotnet/samples/04-hosting/A2A/README.md +++ b/dotnet/samples/02-agents/A2A/README.md @@ -3,7 +3,7 @@ These samples demonstrate how to work with Agent-to-Agent (A2A) specific features in the Agent Framework. For other samples that demonstrate how to use AIAgent instances, -see the [Getting Started With Agents](../../02-agents/Agents/README.md) samples. +see the [Getting Started With Agents](../Agents/README.md) samples. ## Prerequisites @@ -15,6 +15,8 @@ See the README.md for each sample for the prerequisites for that sample. |---|---| |[A2A Agent As Function Tools](./A2AAgent_AsFunctionTools/)|This sample demonstrates how to represent an A2A agent as a set of function tools, where each function tool corresponds to a skill of the A2A agent, and register these function tools with another AI agent so it can leverage the A2A agent's skills.| |[A2A Agent Polling For Task Completion](./A2AAgent_PollingForTaskCompletion/)|This sample demonstrates how to poll for long-running task completion using continuation tokens with an A2A agent.| +|[A2A Agent Stream Reconnection](./A2AAgent_StreamReconnection/)|This sample demonstrates how to reconnect to an A2A agent's streaming response using continuation tokens, allowing recovery from stream interruptions.| +|[A2A Agent Protocol Selection](./A2AAgent_ProtocolSelection/)|This sample demonstrates how to select the A2A protocol binding (HTTP+JSON vs JSON-RPC) when creating an AIAgent from an A2A agent card using A2AClientOptions.| ## Running the samples from the console diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index 5ff0db416d..69f649c9b4 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -19,3 +19,4 @@ The getting started samples demonstrate the fundamental concepts and functionali | [Declarative Agents](./DeclarativeAgents) | Loading and executing AI agents from YAML configuration files | | [AG-UI](./AGUI/README.md) | Getting started with AG-UI (Agent UI Protocol) servers and clients | | [Dev UI](./DevUI/README.md) | Interactive web interface for testing and debugging AI agents during development | +| [A2A Agents](./A2A/README.md) | Working with Agent-to-Agent (A2A) specific features | diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs index 3624acd981..2175e13e71 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AClient/Program.cs @@ -62,12 +62,10 @@ public static class Program } var agentResponse = await hostAgent.Agent!.RunAsync(message, session, cancellationToken: cancellationToken); - foreach (var chatMessage in agentResponse.Messages) - { - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine($"\nAgent: {chatMessage.Text}"); - Console.ResetColor(); - } + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"\nAgent: {agentResponse.Text}"); + Console.ResetColor(); } } catch (Exception ex) diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index e4661f3217..db2412b648 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -14,7 +14,7 @@ namespace A2AServer; internal static class HostAgentFactory { - internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, string[] agentUrls, IList? tools = null) { // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -26,16 +26,16 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; return new(agent, agentCard); } - internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateChatCompletionHostAgentAsync(string agentType, string model, string apiKey, string name, string instructions, string[] agentUrls, IList? tools = null) { AIAgent agent = new OpenAIClient(apiKey) .GetChatClient(model) @@ -43,9 +43,9 @@ internal static class HostAgentFactory AgentCard agentCard = agentType.ToUpperInvariant() switch { - "INVOICE" => GetInvoiceAgentCard(), - "POLICY" => GetPolicyAgentCard(), - "LOGISTICS" => GetLogisticsAgentCard(), + "INVOICE" => GetInvoiceAgentCard(agentUrls), + "POLICY" => GetPolicyAgentCard(agentUrls), + "LOGISTICS" => GetLogisticsAgentCard(agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; @@ -53,7 +53,7 @@ internal static class HostAgentFactory } #region private - private static AgentCard GetInvoiceAgentCard() + private static AgentCard GetInvoiceAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -82,10 +82,11 @@ internal static class HostAgentFactory DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [invoiceQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetPolicyAgentCard() + private static AgentCard GetPolicyAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -114,10 +115,11 @@ internal static class HostAgentFactory DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [policyQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } - private static AgentCard GetLogisticsAgentCard() + private static AgentCard GetLogisticsAgentCard(string[] agentUrls) { var capabilities = new AgentCapabilities() { @@ -146,7 +148,29 @@ internal static class HostAgentFactory DefaultOutputModes = ["text"], Capabilities = capabilities, Skills = [logisticsQuery], + SupportedInterfaces = CreateAgentInterfaces(agentUrls) }; } + + private static List CreateAgentInterfaces(string[] agentUrls) + { + List agentInterfaces = []; + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = ProtocolBindingNames.JsonRpc, + ProtocolVersion = "1.0", + })); + + agentInterfaces.AddRange(agentUrls.Select(url => new AgentInterface + { + Url = url, + ProtocolBinding = ProtocolBindingNames.HttpJson, + ProtocolVersion = "1.0", + })); + + return agentInterfaces; + } #endregion } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index 8dcb3d1a34..c12a1c9431 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -25,10 +25,6 @@ for (var i = 0; i < args.Length; i++) var builder = WebApplication.CreateBuilder(args); builder.Services.AddHttpClient().AddLogging(); -var app = builder.Build(); - -var httpClient = app.Services.GetRequiredService().CreateClient(); -var logger = app.Logger; IConfigurationRoot configuration = new ConfigurationBuilder() .AddEnvironmentVariables() @@ -38,14 +34,15 @@ IConfigurationRoot configuration = new ConfigurationBuilder() string? apiKey = configuration["OPENAI_API_KEY"]; string model = configuration["OPENAI_CHAT_MODEL_NAME"] ?? "gpt-5.4-mini"; string? endpoint = configuration["AZURE_AI_PROJECT_ENDPOINT"]; +string[] agentUrls = (builder.Configuration["urls"] ?? "http://localhost:5000").Split(';'); var invoiceQueryPlugin = new InvoiceQuery(); IList tools = - [ +[ AIFunctionFactory.Create(invoiceQueryPlugin.QueryInvoices), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByTransactionId), AIFunctionFactory.Create(invoiceQueryPlugin.QueryByInvoiceId) - ]; +]; AIAgent hostA2AAgent; AgentCard hostA2AAgentCard; @@ -54,9 +51,9 @@ if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName)) { (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { - "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools), - "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), - "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), + "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls, tools), + "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), + "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -68,7 +65,7 @@ else if (!string.IsNullOrEmpty(apiKey)) agentType, model, apiKey, "InvoiceAgent", """ You specialize in handling queries related to invoices. - """, tools), + """, agentUrls, tools), "POLICY" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "PolicyAgent", """ @@ -84,7 +81,7 @@ else if (!string.IsNullOrEmpty(apiKey)) resolution in SAP CRM and notify the customer via email within 2 business days, referencing the original invoice and the credit memo number. Use the 'Formal Credit Notification' email template." - """), + """, agentUrls), "LOGISTICS" => await HostAgentFactory.CreateChatCompletionHostAgentAsync( agentType, model, apiKey, "LogisticsAgent", """ @@ -95,7 +92,7 @@ else if (!string.IsNullOrEmpty(apiKey)) Shipment number: SHPMT-SAP-001 Item: TSHIRT-RED-L Quantity: 900 - """), + """, agentUrls), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -104,10 +101,12 @@ else throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } -var a2aTaskManager = app.MapA2A( - hostA2AAgent, - path: "/", - agentCard: hostA2AAgentCard, - taskManager => app.MapWellKnownAgentCard(taskManager, "/")); +builder.AddA2AServer(hostA2AAgent); + +var app = builder.Build(); +app.MapA2AHttpJson(hostA2AAgent, "/"); +app.MapA2AJsonRpc(hostA2AAgent, "/"); + +app.MapWellKnownAgentCard(hostA2AAgentCard); await app.RunAsync(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs index 15e7cbbd86..d6957a9b8e 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.AgentHost/Program.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using A2A.AspNetCore; using AgentWebChat.AgentHost; using AgentWebChat.AgentHost.Custom; using AgentWebChat.AgentHost.Utilities; @@ -146,6 +145,9 @@ builder.Services.AddKeyedSingleton("my-di-matchingname-agent", (sp, nam instructions: "you are a dependency inject agent. Tell me all about dependency injection."); }); +pirateAgentBuilder.AddA2AServer(); +knightsKnavesAgentBuilder.AddA2AServer(); + var app = builder.Build(); app.MapOpenApi(); @@ -154,17 +156,9 @@ app.UseSwaggerUI(options => options.SwaggerEndpoint("/openapi/v1.json", "Agents // Configure the HTTP request pipeline. app.UseExceptionHandler(); -// attach a2a with simple message communication -app.MapA2A(pirateAgentBuilder, path: "/a2a/pirate"); -app.MapA2A(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves", agentCard: new() -{ - Name = "Knights and Knaves", - Description = "An agent that helps you solve the knights and knaves puzzle.", - Version = "1.0", - - // Url can be not set, and SDK will help assign it. - // Url = "http://localhost:5390/a2a/knights-and-knaves" -}); +// Expose A2A servers over HTTP with JSON payloads +app.MapA2AHttpJson(pirateAgentBuilder, path: "/a2a/pirate"); +app.MapA2AHttpJson(knightsKnavesAgentBuilder, path: "/a2a/knights-and-knaves"); app.MapDevUI(); diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs index f790ec0daa..d2c67d0ca5 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/A2AAgentClient.cs @@ -43,20 +43,21 @@ internal sealed class A2AAgentClient : AgentClientBase { // Convert all messages to A2A parts and create a single message var parts = messages.ToParts(); - var a2aMessage = new AgentMessage + var a2aMessage = new Message { MessageId = Guid.NewGuid().ToString("N"), ContextId = contextId, - Role = MessageRole.User, + Role = Role.User, Parts = parts }; - var messageSendParams = new MessageSendParams { Message = a2aMessage }; + var messageSendParams = new SendMessageRequest { Message = a2aMessage }; var a2aResponse = await a2aClient.SendMessageAsync(messageSendParams, cancellationToken); // Handle different response types - if (a2aResponse is AgentMessage message) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Message) { + var message = a2aResponse.Message!; var responseMessage = message.ToChatMessage(); if (responseMessage is { Contents.Count: > 0 }) { @@ -67,9 +68,10 @@ internal sealed class A2AAgentClient : AgentClientBase }); } } - else if (a2aResponse is AgentTask agentTask) + else if (a2aResponse.PayloadCase == SendMessageResponseCase.Task) { // Manually convert AgentTask artifacts to ChatMessages since the extension method is internal + var agentTask = a2aResponse.Task!; if (agentTask.Artifacts is not null) { foreach (var artifact in agentTask.Artifacts) diff --git a/dotnet/samples/README.md b/dotnet/samples/README.md index 577b8bccbd..063e5cfc3f 100644 --- a/dotnet/samples/README.md +++ b/dotnet/samples/README.md @@ -16,7 +16,7 @@ were local agents. These are supported using various `AIAgent` subclasses. | [`01-get-started/`](./01-get-started/) | Progressive tutorial: hello agent → hosting | | [`02-agents/`](./02-agents/) | Deep-dive by concept: tools, middleware, providers, orchestrations | | [`03-workflows/`](./03-workflows/) | Workflow patterns: sequential, concurrent, state, declarative | -| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks, A2A | +| [`04-hosting/`](./04-hosting/) | Deployment: Azure Functions, Durable Tasks | | [`05-end-to-end/`](./05-end-to-end/) | Full applications, evaluation, demos | ## Getting Started diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs index 9d98857e9b..9fd2e8ff47 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgent.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Net.ServerSentEvents; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -28,7 +27,7 @@ public sealed class A2AAgent : AIAgent { private static readonly AIAgentMetadata s_agentMetadata = new("a2a"); - private readonly A2AClient _a2aClient; + private readonly IA2AClient _a2aClient; private readonly string? _id; private readonly string? _name; private readonly string? _description; @@ -42,7 +41,7 @@ public sealed class A2AAgent : AIAgent /// The the name of the agent. /// The description of the agent. /// Optional logger factory to use for logging. - public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) + public A2AAgent(IA2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) { _ = Throw.IfNull(a2aClient); @@ -100,64 +99,47 @@ public sealed class A2AAgent : AIAgent this._logger.LogA2AAgentInvokingAgent(nameof(RunAsync), this.Id, this.Name); - A2AResponse? a2aResponse = null; - if (GetContinuationToken(messages, options) is { } token) { - a2aResponse = await this._a2aClient.GetTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); - } - else - { - MessageSendParams sendParams = new() - { - Message = CreateA2AMessage(typedSession, messages), - Metadata = options?.AdditionalProperties?.ToA2AMetadata() - }; + AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = token.TaskId }, cancellationToken).ConfigureAwait(false); - a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); + + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); + + return this.ConvertToAgentResponse(agentTask); } + SendMessageRequest sendParams = new() + { + Message = CreateA2AMessage(typedSession, messages), + Metadata = options?.AdditionalProperties?.ToA2AMetadata(), + Configuration = new SendMessageConfiguration { ReturnImmediately = options?.AllowBackgroundResponses is true } + }; + + SendMessageResponse a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name); - if (a2aResponse is AgentMessage message) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Message) { + var message = a2aResponse.Message!; + UpdateSession(typedSession, message.ContextId); - return new AgentResponse - { - AgentId = this.Id, - ResponseId = message.MessageId, - FinishReason = ChatFinishReason.Stop, - RawRepresentation = message, - Messages = [message.ToChatMessage()], - AdditionalProperties = message.Metadata?.ToAdditionalProperties(), - }; + return this.ConvertToAgentResponse(message); } - if (a2aResponse is AgentTask agentTask) + if (a2aResponse.PayloadCase == SendMessageResponseCase.Task) { + var agentTask = a2aResponse.Task!; + UpdateSession(typedSession, agentTask.ContextId, agentTask.Id); - var response = new AgentResponse - { - AgentId = this.Id, - ResponseId = agentTask.Id, - FinishReason = MapTaskStateToFinishReason(agentTask.Status.State), - RawRepresentation = agentTask, - Messages = agentTask.ToChatMessages() ?? [], - ContinuationToken = CreateContinuationToken(agentTask.Id, agentTask.Status.State), - AdditionalProperties = agentTask.Metadata?.ToAdditionalProperties(), - }; - - if (agentTask.ToChatMessages() is { Count: > 0 } taskMessages) - { - response.Messages = taskMessages; - } - - return response; + return this.ConvertToAgentResponse(agentTask); } - throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.GetType().FullName ?? "null"}"); + throw new NotSupportedException($"Only Message and AgentTask responses are supported from A2A agents. Received: {a2aResponse.PayloadCase}"); } /// @@ -169,59 +151,61 @@ public sealed class A2AAgent : AIAgent this._logger.LogA2AAgentInvokingAgent(nameof(RunStreamingAsync), this.Id, this.Name); - ConfiguredCancelableAsyncEnumerable> a2aSseEvents; + ConfiguredCancelableAsyncEnumerable streamEvents; - if (options?.ContinuationToken is not null) + if (GetContinuationToken(messages, options) is { } token) { - // Task stream resumption is not well defined in the A2A v2.* specification, leaving it to the agent implementations. - // The v3.0 specification improves this by defining task stream reconnection that allows obtaining the same stream - // from the beginning, but it does not define stream resumption from a specific point in the stream. - // Therefore, the code should be updated once the A2A .NET library supports the A2A v3.0 specification, - // and AF has the necessary model to allow consumers to know whether they need to resume the stream and add new updates to - // the existing ones or reconnect the stream and obtain all updates again. - // For more details, see the following issue: https://github.com/microsoft/agent-framework/issues/1764 - throw new InvalidOperationException("Reconnecting to task streams using continuation tokens is not supported yet."); - // a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false); + streamEvents = this.SubscribeToTaskWithFallbackAsync(token.TaskId, cancellationToken).ConfigureAwait(false); } - - MessageSendParams sendParams = new() + else { - Message = CreateA2AMessage(typedSession, messages), - Metadata = options?.AdditionalProperties?.ToA2AMetadata() - }; + SendMessageRequest sendParams = new() + { + Message = CreateA2AMessage(typedSession, messages), + Metadata = options?.AdditionalProperties?.ToA2AMetadata() + }; - a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false); + streamEvents = this._a2aClient.SendStreamingMessageAsync(sendParams, cancellationToken).ConfigureAwait(false); + } this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name); string? contextId = null; string? taskId = null; - await foreach (var sseEvent in a2aSseEvents) + await foreach (var streamResponse in streamEvents) { - if (sseEvent.Data is AgentMessage message) + switch (streamResponse.PayloadCase) { - contextId = message.ContextId; + case StreamResponseCase.Message: + var message = streamResponse.Message!; + contextId = message.ContextId; + yield return this.ConvertToAgentResponseUpdate(message); + break; - yield return this.ConvertToAgentResponseUpdate(message); - } - else if (sseEvent.Data is AgentTask task) - { - contextId = task.ContextId; - taskId = task.Id; + case StreamResponseCase.Task: + var task = streamResponse.Task!; + contextId = task.ContextId; + taskId = task.Id; + yield return this.ConvertToAgentResponseUpdate(task); + break; - yield return this.ConvertToAgentResponseUpdate(task); - } - else if (sseEvent.Data is TaskUpdateEvent taskUpdateEvent) - { - contextId = taskUpdateEvent.ContextId; - taskId = taskUpdateEvent.TaskId; + case StreamResponseCase.StatusUpdate: + var statusUpdate = streamResponse.StatusUpdate!; + contextId = statusUpdate.ContextId; + taskId = statusUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(statusUpdate); + break; - yield return this.ConvertToAgentResponseUpdate(taskUpdateEvent); - } - else - { - throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {sseEvent.Data.GetType().FullName ?? "null"}"); + case StreamResponseCase.ArtifactUpdate: + var artifactUpdate = streamResponse.ArtifactUpdate!; + contextId = artifactUpdate.ContextId; + taskId = artifactUpdate.TaskId; + yield return this.ConvertToAgentResponseUpdate(artifactUpdate); + break; + + default: + throw new NotSupportedException($"Only message, task, task update events are supported from A2A agents. Received: {streamResponse.PayloadCase}"); } } @@ -240,7 +224,7 @@ public sealed class A2AAgent : AIAgent /// public override object? GetService(Type serviceType, object? serviceKey = null) => base.GetService(serviceType, serviceKey) - ?? (serviceType == typeof(A2AClient) ? this._a2aClient + ?? (serviceType == typeof(IA2AClient) ? this._a2aClient : serviceType == typeof(AIAgentMetadata) ? s_agentMetadata : null); @@ -264,6 +248,75 @@ public sealed class A2AAgent : AIAgent return typedSession; } + /// + /// Subscribes to task updates, falling back to + /// when the task has already reached a terminal state and the server responds with + /// . + /// + /// + /// Per A2A spec §3.1.6, subscribing to a task in a terminal state (completed, failed, + /// canceled, or rejected) results in an UnsupportedOperationError. + /// See: . + /// + private async IAsyncEnumerable SubscribeToTaskWithFallbackAsync( + string taskId, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var subscribeStream = this._a2aClient.SubscribeToTaskAsync(new SubscribeToTaskRequest { Id = taskId }, cancellationToken); + + var enumerator = subscribeStream.GetAsyncEnumerator(cancellationToken); + + // yield return cannot appear inside a try block that has catch clauses, + // so we manually advance the enumerator within try/catch and yield outside it. + // The outer try/finally (no catch) is allowed to contain yield return in C#. + StreamResponse? fallbackResponse = null; + bool disposed = false; + + try + { + while (true) + { + bool hasNext; + try + { + hasNext = await enumerator.MoveNextAsync().ConfigureAwait(false); + } + catch (A2AException ex) when (ex.ErrorCode == A2AErrorCode.UnsupportedOperation) + { + this._logger.LogA2ASubscribeToTaskFallback(this.Id, this.Name, taskId, ex.Message); + + // Dispose the enumerator before the fallback call to release the HTTP/SSE connection. + await enumerator.DisposeAsync().ConfigureAwait(false); + disposed = true; + + AgentTask agentTask = await this._a2aClient.GetTaskAsync(new GetTaskRequest { Id = taskId }, cancellationToken).ConfigureAwait(false); + + fallbackResponse = new StreamResponse { Task = agentTask }; + break; + } + + if (!hasNext) + { + break; + } + + yield return enumerator.Current; + } + + if (fallbackResponse is not null) + { + yield return fallbackResponse; + } + } + finally + { + if (!disposed) + { + await enumerator.DisposeAsync().ConfigureAwait(false); + } + } + } + private static void UpdateSession(A2AAgentSession? session, string? contextId, string? taskId = null) { if (session is null) @@ -284,7 +337,7 @@ public sealed class A2AAgent : AIAgent session.TaskId = taskId; } - private static AgentMessage CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) + private static Message CreateA2AMessage(A2AAgentSession typedSession, IEnumerable messages) { var a2aMessage = messages.ToA2AMessage(); @@ -324,7 +377,34 @@ public sealed class A2AAgent : AIAgent return null; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(AgentMessage message) + private AgentResponse ConvertToAgentResponse(Message message) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = message.MessageId, + FinishReason = ChatFinishReason.Stop, + RawRepresentation = message, + Messages = [message.ToChatMessage()], + AdditionalProperties = message.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponse ConvertToAgentResponse(AgentTask task) + { + return new AgentResponse + { + AgentId = this.Id, + ResponseId = task.Id, + FinishReason = MapTaskStateToFinishReason(task.Status.State), + RawRepresentation = task, + Messages = task.ToChatMessages() ?? [], + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), + AdditionalProperties = task.Metadata?.ToAdditionalProperties(), + }; + } + + private AgentResponseUpdate ConvertToAgentResponseUpdate(Message message) { return new AgentResponseUpdate { @@ -349,32 +429,35 @@ public sealed class A2AAgent : AIAgent RawRepresentation = task, Role = ChatRole.Assistant, Contents = task.ToAIContents(), + ContinuationToken = CreateContinuationToken(task.Id, task.Status.State), AdditionalProperties = task.Metadata?.ToAdditionalProperties(), }; } - private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskUpdateEvent taskUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskStatusUpdateEvent statusUpdateEvent) { - AgentResponseUpdate responseUpdate = new() + return new AgentResponseUpdate { AgentId = this.Id, - ResponseId = taskUpdateEvent.TaskId, - RawRepresentation = taskUpdateEvent, + ResponseId = statusUpdateEvent.TaskId, + RawRepresentation = statusUpdateEvent, Role = ChatRole.Assistant, - AdditionalProperties = taskUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State), + AdditionalProperties = statusUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], }; + } - if (taskUpdateEvent is TaskArtifactUpdateEvent artifactUpdateEvent) + private AgentResponseUpdate ConvertToAgentResponseUpdate(TaskArtifactUpdateEvent artifactUpdateEvent) + { + return new AgentResponseUpdate { - responseUpdate.Contents = artifactUpdateEvent.Artifact.ToAIContents(); - responseUpdate.RawRepresentation = artifactUpdateEvent; - } - else if (taskUpdateEvent is TaskStatusUpdateEvent statusUpdateEvent) - { - responseUpdate.FinishReason = MapTaskStateToFinishReason(statusUpdateEvent.Status.State); - } - - return responseUpdate; + AgentId = this.Id, + ResponseId = artifactUpdateEvent.TaskId, + RawRepresentation = artifactUpdateEvent, + Role = ChatRole.Assistant, + Contents = artifactUpdateEvent.Artifact.ToAIContents(), + AdditionalProperties = artifactUpdateEvent.Metadata?.ToAdditionalProperties() ?? [], + }; } private static ChatFinishReason? MapTaskStateToFinishReason(TaskState state) diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs index 96d0ba0f9f..7d72013ba3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AAgentLogMessages.cs @@ -34,4 +34,17 @@ internal static partial class A2AAgentLogMessages string methodName, string agentId, string? agentName); + + /// + /// Logs falling back to GetTaskAsync after SubscribeToTaskAsync failed with UnsupportedOperation. + /// + [LoggerMessage( + Level = LogLevel.Warning, + Message = "A2AAgent {AgentId}/{AgentName} SubscribeToTask for task '{TaskId}' failed with UnsupportedOperation: {ErrorMessage}. Falling back to GetTaskAsync.")] + public static partial void LogA2ASubscribeToTaskFallback( + this ILogger logger, + string agentId, + string? agentName, + string taskId, + string errorMessage); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs index 5233adb88f..845e1dc6e3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/A2AContinuationToken.cs @@ -52,7 +52,7 @@ internal class A2AContinuationToken : ResponseContinuationToken { case "taskId": reader.Read(); - taskId = reader.GetString()!; + taskId = reader.GetString() ?? throw new JsonException("The 'taskId' property must contain a non-null string value."); break; default: throw new JsonException($"Unrecognized property '{propertyName}'."); diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs index 1998d020b5..086505b2bc 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AAgentCardExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Net.Http; using Microsoft.Agents.AI; using Microsoft.Extensions.Logging; @@ -25,12 +24,15 @@ public static class A2AAgentCardExtensions /// /// The to use for the agent creation. /// The to use for HTTP requests. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. + /// /// The logger factory for enabling logging within the agent. /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null) + public static AIAgent AsAIAgent(this AgentCard card, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null) { - // Create the A2A client using the agent URL from the card. - var a2aClient = new A2AClient(new Uri(card.Url), httpClient); + var a2aClient = A2AClientFactory.Create(card, httpClient, options); return a2aClient.AsAIAgent(name: card.Name, description: card.Description, loggerFactory: loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs index 6a32822fea..49b6de1102 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2ACardResolverExtensions.cs @@ -34,14 +34,18 @@ public static class A2ACardResolverExtensions /// /// The to use for the agent creation. /// The to use for HTTP requests. + /// + /// Optional controlling protocol binding preference. + /// When not provided, defaults to preferring HTTP+JSON first, with JSON-RPC as fallback. + /// /// The logger factory for enabling logging within the agent. /// The to monitor for cancellation requests. The default is . /// An instance backed by the A2A agent. - public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) + public static async Task GetAIAgentAsync(this A2ACardResolver resolver, HttpClient? httpClient = null, A2AClientOptions? options = null, ILoggerFactory? loggerFactory = null, CancellationToken cancellationToken = default) { // Obtain the agent card from the resolver. var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false); - return agentCard.AsAIAgent(httpClient, loggerFactory); + return agentCard.AsAIAgent(httpClient, options, loggerFactory); } } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs index cd93ca0bac..c7386309d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/A2AClientExtensions.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Logging; namespace A2A; /// -/// Provides extension methods for +/// Provides extension methods for /// to simplify the creation of A2A agents. /// /// @@ -29,12 +29,12 @@ public static class A2AClientExtensions /// Direct Configuration / Private Discovery /// discovery mechanism. /// - /// The to use for the agent. + /// The to use for the agent. /// The unique identifier for the agent. /// The the name of the agent. /// The description of the agent. /// Optional logger factory for enabling logging within the agent. /// An instance backed by the A2A agent. - public static AIAgent AsAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => + public static AIAgent AsAIAgent(this IA2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) => new A2AAgent(client, id, name, description, loggerFactory); } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs index b1f1bd643a..dd0749ecc9 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Extensions/ChatMessageExtensions.cs @@ -11,7 +11,7 @@ namespace Microsoft.Extensions.AI; /// internal static class ChatMessageExtensions { - internal static AgentMessage ToA2AMessage(this IEnumerable messages) + internal static Message ToA2AMessage(this IEnumerable messages) { List allParts = []; @@ -23,10 +23,10 @@ internal static class ChatMessageExtensions } } - return new AgentMessage + return new Message { MessageId = Guid.NewGuid().ToString("N"), - Role = MessageRole.User, + Role = Role.User, Parts = allParts, }; } diff --git a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj index b1b9ba7671..4e92826f56 100644 --- a/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj +++ b/dotnet/src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj @@ -1,6 +1,7 @@ + $(TargetFrameworksCore) preview $(NoWarn);MEAI001 diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs new file mode 100644 index 0000000000..7bfd0db8df --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/A2AEndpointRouteBuilderExtensions.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using A2A; +using A2A.AspNetCore; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.AspNetCore.Builder; + +/// +/// Provides extension methods for mapping A2A protocol endpoints for AI agents. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public static class A2AEndpointRouteBuilderExtensions +{ + /// + /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for the agent. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + return endpoints.MapA2AHttpJson(agentBuilder.Name, path); + } + + /// + /// Maps A2A HTTP+JSON endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The agent whose name identifies the registered A2A server. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + return endpoints.MapA2AHttpJson(agent.Name, path); + } + + /// + /// Maps A2A HTTP+JSON endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AHttpJson(this IEndpointRouteBuilder endpoints, string agentName, string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName) + ?? throw new InvalidOperationException( + $"No A2AServer is registered for agent '{agentName}'. " + + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); + + // TODO: The stub AgentCard is temporary and will be removed once the A2A SDK either removes the + // agentCard parameter of MapHttpA2A or makes it optional. MapHttpA2A exposes the agent card via a + // GET {path}/card endpoint that is not part of the A2A spec, so it is not expected to be consumed + // by any agent - returning a stub agent card here is safe. + var stubAgentCard = new AgentCard { Name = "A2A Agent" }; + + return endpoints.MapHttpA2A(a2aServer, stubAgentCard, path); + } + + /// + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The configuration builder for the agent. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + return endpoints.MapA2AJsonRpc(agentBuilder.Name, path); + } + + /// + /// Maps A2A JSON-RPC endpoints for the specified agent to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The agent whose name identifies the registered A2A server. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + return endpoints.MapA2AJsonRpc(agent.Name, path); + } + + /// + /// Maps A2A JSON-RPC endpoints for the agent with the specified name to the given path. + /// An for the agent must be registered first by calling + /// AddA2AServer during service registration. + /// + /// The to add the A2A endpoints to. + /// The name of the agent to use for A2A protocol integration. + /// The route path prefix for A2A endpoints. + /// An for further endpoint configuration. + public static IEndpointConventionBuilder MapA2AJsonRpc(this IEndpointRouteBuilder endpoints, string agentName, string path) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var a2aServer = endpoints.ServiceProvider.GetKeyedService(agentName) + ?? throw new InvalidOperationException( + $"No A2AServer is registered for agent '{agentName}'. " + + $"Call services.AddA2AServer(\"{agentName}\") or agentBuilder.AddA2AServer() during service registration to register one."); + + return endpoints.MapA2A(a2aServer, path); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs deleted file mode 100644 index af3ff093ee..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/EndpointRouteBuilderExtensions.cs +++ /dev/null @@ -1,385 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Diagnostics.CodeAnalysis; -using A2A; -using A2A.AspNetCore; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting; -using Microsoft.Agents.AI.Hosting.A2A; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.AspNetCore.Builder; - -/// -/// Provides extension methods for configuring A2A (Agent2Agent) communication in a host application builder. -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -public static class MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions -{ - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path) - => endpoints.MapA2A(agentBuilder, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path) - => endpoints.MapA2A(agentName, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard) - => endpoints.MapA2A(agentBuilder, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard) - => endpoints.MapA2A(agentName, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The configuration builder for . - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, IHostedAgentBuilder agentBuilder, string path, AgentCard agentCard, Action configureTaskManager) - { - ArgumentNullException.ThrowIfNull(agentBuilder); - return endpoints.MapA2A(agentBuilder.Name, path, agentCard, configureTaskManager); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agentName, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The name of the agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, string agentName, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - var agent = endpoints.ServiceProvider.GetRequiredKeyedService(agentName); - return endpoints.MapA2A(agent, path, agentCard, configureTaskManager, agentRunMode); - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path) - => endpoints.MapA2A(agent, path, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager) - => endpoints.MapA2A(agent, path, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agent); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(loggerFactory: loggerFactory, agentSessionStore: agentSessionStore, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); - return endpointConventionBuilder; - } - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard) - => endpoints.MapA2A(agent, path, agentCard, _ => { }); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, AgentRunMode agentRunMode) - => endpoints.MapA2A(agent, path, agentCard, _ => { }, agentRunMode); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager) - => endpoints.MapA2A(agent, path, agentCard, configureTaskManager, AgentRunMode.DisallowBackground); - - /// - /// Attaches A2A (Agent2Agent) communication capabilities via Message processing to the specified web application. - /// - /// The to add the A2A endpoints to. - /// The agent to use for A2A protocol integration. - /// The route group to use for A2A endpoints. - /// Agent card info to return on query. - /// The callback to configure . - /// Controls the response behavior of the agent run. - /// Configured for A2A integration. - /// - /// This method can be used to access A2A agents that support the - /// Curated Registries (Catalog-Based Discovery) - /// discovery mechanism. - /// - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, AIAgent agent, string path, AgentCard agentCard, Action configureTaskManager, AgentRunMode agentRunMode) - { - ArgumentNullException.ThrowIfNull(endpoints); - ArgumentNullException.ThrowIfNull(agent); - - var loggerFactory = endpoints.ServiceProvider.GetRequiredService(); - var agentSessionStore = endpoints.ServiceProvider.GetKeyedService(agent.Name); - var taskManager = agent.MapA2A(agentCard: agentCard, agentSessionStore: agentSessionStore, loggerFactory: loggerFactory, runMode: agentRunMode); - var endpointConventionBuilder = endpoints.MapA2A(taskManager, path); - - configureTaskManager(taskManager); - - return endpointConventionBuilder; - } - - /// - /// Maps HTTP A2A communication endpoints to the specified path using the provided TaskManager. - /// TaskManager should be preconfigured before calling this method. - /// - /// The to add the A2A endpoints to. - /// Pre-configured A2A TaskManager to use for A2A endpoints handling. - /// The route group to use for A2A endpoints. - /// Configured for A2A integration. - public static IEndpointConventionBuilder MapA2A(this IEndpointRouteBuilder endpoints, ITaskManager taskManager, string path) - { - // note: current SDK version registers multiple `.well-known/agent.json` handlers here. - // it makes app return HTTP 500, but will be fixed once new A2A SDK is released. - // see https://github.com/microsoft/agent-framework/issues/476 for details - A2ARouteBuilderExtensions.MapA2A(endpoints, taskManager, path); - return endpoints.MapHttpA2A(taskManager, path); - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj index 4829b56b9e..200aa29ccc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj @@ -1,9 +1,12 @@ - + $(TargetFrameworksCore) Microsoft.Agents.AI.Hosting.A2A.AspNetCore preview + + $(NoWarn);RT0002 @@ -13,7 +16,7 @@ true true - + @@ -21,7 +24,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs new file mode 100644 index 0000000000..65bc4c30bd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Agents.AI.Hosting.A2A.Converters; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// An implementation that bridges an to the +/// A2A (Agent2Agent) protocol. Handles message execution and cancellation by delegating to +/// the underlying agent and translating responses into A2A events. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +internal sealed class A2AAgentHandler : IAgentHandler +{ + private readonly AIHostAgent _hostAgent; + private readonly AgentRunMode _runMode; + + /// + /// Initializes a new instance of the class. + /// + /// The hosted agent that provides the execution logic. + /// Controls whether the agent runs in background mode. + public A2AAgentHandler( + AIHostAgent hostAgent, + AgentRunMode runMode) + { + ArgumentNullException.ThrowIfNull(hostAgent); + ArgumentNullException.ThrowIfNull(runMode); + + this._hostAgent = hostAgent; + this._runMode = runMode; + } + + /// + public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + if (context.IsContinuation) + { + return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken); + } + + return this.HandleNewMessageAsync(context, eventQueue, cancellationToken); + } + + /// + public async Task CancelAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, context.ContextId); + await taskUpdater.CancelAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task HandleNewMessageAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // AIAgent does not support resuming from arbitrary prior tasks. + // Throw explicitly so the client gets a clear error rather than a response + // that silently ignores the referenced task context. + if (context.Message?.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context."); + } + + List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : []; + + // Decide whether to run in background based on user preferences and agent capabilities + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + var response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Return a lightweight message response (no task lifecycle needed). + var message = CreateMessageFromResponse(contextId, response); + await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + else + { + // Long-running operation: emit task lifecycle events. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.SubmitAsync(cancellationToken).ConfigureAwait(false); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + List chatMessages = ExtractChatMessagesFromTaskHistory(context.Task); + + var decisionContext = new A2ARunDecisionContext(context); + var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); + + var options = context.Metadata is not { Count: > 0 } + ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } + : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; + + AgentResponse response; + try + { + response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception) + { + var failUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false); + throw; + } + + await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + + if (response.ContinuationToken is null) + { + // Complete the task with an artifact containing the response. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + await taskUpdater.AddArtifactAsync(response.Messages.ToParts(), cancellationToken: cancellationToken).ConfigureAwait(false); + await taskUpdater.CompleteAsync(message: null, cancellationToken).ConfigureAwait(false); + } + else + { + // Still working: emit progress status. + var taskUpdater = new TaskUpdater(eventQueue, context.TaskId, contextId); + + Message? progressMessage = response.Messages.Count > 0 + ? CreateMessageFromResponse(contextId, response) + : null; + + await taskUpdater.StartWorkAsync(progressMessage, cancellationToken).ConfigureAwait(false); + } + } + + private static Message CreateMessageFromResponse(string contextId, AgentResponse response) => + new() + { + MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.Agent, + Parts = response.Messages.ToParts(), + Metadata = response.AdditionalProperties?.ToA2AMetadata() + }; + + private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask) + { + if (agentTask?.History is not { Count: > 0 }) + { + return []; + } + + var chatMessages = new List(agentTask.History.Count); + foreach (var message in agentTask.History) + { + chatMessages.Add(message.ToChatMessage()); + } + + return chatMessages; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs index 6ff49f6ecb..3e78afea8c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2ARunDecisionContext.cs @@ -9,13 +9,13 @@ namespace Microsoft.Agents.AI.Hosting.A2A; /// public sealed class A2ARunDecisionContext { - internal A2ARunDecisionContext(MessageSendParams messageSendParams) + internal A2ARunDecisionContext(RequestContext requestContext) { - this.MessageSendParams = messageSendParams; + this.RequestContext = requestContext; } /// - /// Gets the parameters of the incoming A2A message that triggered this run. + /// Gets the request context of the incoming A2A request that triggered this run. /// - public MessageSendParams MessageSendParams { get; } + public RequestContext RequestContext { get; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs new file mode 100644 index 0000000000..7bd30f9a7c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerRegistrationOptions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using A2A; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Hosting.A2A; + +/// +/// Options for configuring A2A server registration. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public sealed class A2AServerRegistrationOptions +{ + /// + /// Gets or sets the agent run mode that controls how the agent responds to A2A requests. + /// + /// + /// When , defaults to . + /// + public AgentRunMode? AgentRunMode { get; set; } + + /// + /// Gets or sets the A2A server options used to configure the underlying . + /// + /// + /// When , no custom server options are applied. + /// + public A2AServerOptions? ServerOptions { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs new file mode 100644 index 0000000000..29ab28c250 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AServerServiceCollectionExtensions.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using A2A; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting; +using Microsoft.Agents.AI.Hosting.A2A; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Provides extension methods for registering A2A server instances in the dependency injection container. +/// +[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] +public static class A2AServerServiceCollectionExtensions +{ + /// + /// Registers an in the dependency injection container, keyed by the agent name + /// specified in the . This method only registers the server; to expose it + /// as an HTTP endpoint, call one of the MapA2AHttpJson or MapA2AJsonRpc endpoint mapping + /// methods during application startup. + /// + /// The agent builder whose name identifies the agent. + /// An optional callback to configure . + /// The for chaining. + public static IHostedAgentBuilder AddA2AServer(this IHostedAgentBuilder agentBuilder, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(agentBuilder); + + agentBuilder.ServiceCollection.AddA2AServer(agentBuilder.Name, configureOptions); + + return agentBuilder; + } + + /// + /// Registers an in the dependency injection container, keyed by the specified + /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the + /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The host application builder to configure. + /// The name of the agent to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, string agentName, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddA2AServer(agentName, configureOptions); + + return builder; + } + + /// + /// Registers an in the dependency injection container for the specified + /// instance, keyed by the agent's . This method only + /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or + /// MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The host application builder to configure. + /// The agent instance to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IHostApplicationBuilder AddA2AServer(this IHostApplicationBuilder builder, AIAgent agent, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.AddA2AServer(agent, configureOptions); + + return builder; + } + + /// + /// Registers an in the dependency injection container, keyed by the specified + /// agent name. This method only registers the server; to expose it as an HTTP endpoint, call one of the + /// MapA2AHttpJson or MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The service collection to add the A2A server to. + /// The name of the agent to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IServiceCollection AddA2AServer(this IServiceCollection services, string agentName, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(agentName); + + A2AServerRegistrationOptions? options = null; + if (configureOptions is not null) + { + options = new A2AServerRegistrationOptions(); + configureOptions(options); + } + + services.AddKeyedSingleton(agentName, (sp, _) => + { + var agent = sp.GetRequiredKeyedService(agentName); + return CreateA2AServer(sp, agent, options); + }); + + return services; + } + + /// + /// Registers an in the dependency injection container for the specified + /// instance, keyed by the agent's . This method only + /// registers the server; to expose it as an HTTP endpoint, call one of the MapA2AHttpJson or + /// MapA2AJsonRpc endpoint mapping methods during application startup. + /// + /// The service collection to add the A2A server to. + /// The agent instance to create an A2A server for. + /// An optional callback to configure . + /// The for chaining. + public static IServiceCollection AddA2AServer(this IServiceCollection services, AIAgent agent, Action? configureOptions = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(agent); + ArgumentException.ThrowIfNullOrWhiteSpace(agent.Name, nameof(agent) + "." + nameof(agent.Name)); + + A2AServerRegistrationOptions? options = null; + if (configureOptions is not null) + { + options = new A2AServerRegistrationOptions(); + configureOptions(options); + } + + services.AddKeyedSingleton(agent.Name, (sp, _) => CreateA2AServer(sp, agent, options)); + + return services; + } + + private static A2AServer CreateA2AServer(IServiceProvider serviceProvider, AIAgent agent, A2AServerRegistrationOptions? options) + { + var agentHandler = serviceProvider.GetKeyedService(agent.Name); + if (agentHandler is null) + { + var agentSessionStore = serviceProvider.GetKeyedService(agent.Name); + var runMode = options?.AgentRunMode ?? AgentRunMode.DisallowBackground; + + var hostAgent = new AIHostAgent( + innerAgent: agent, + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + + agentHandler = new A2AAgentHandler(hostAgent, runMode); + } + + var loggerFactory = serviceProvider.GetService() ?? NullLoggerFactory.Instance; + var taskStore = serviceProvider.GetKeyedService(agent.Name) ?? new InMemoryTaskStore(); + + return new A2AServer( + agentHandler, + taskStore, + new ChannelEventNotifier(), + loggerFactory.CreateLogger(), + options?.ServerOptions); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs deleted file mode 100644 index 31c520755f..0000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AIAgentExtensions.cs +++ /dev/null @@ -1,309 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.Agents.AI.Hosting.A2A.Converters; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.DiagnosticIds; - -namespace Microsoft.Agents.AI.Hosting.A2A; - -/// -/// Provides extension methods for attaching A2A (Agent2Agent) messaging capabilities to an . -/// -[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)] -public static class AIAgentExtensions -{ - // Metadata key used to store continuation tokens for long-running background operations - // in the AgentTask.Metadata dictionary, persisted by the task store. - private const string ContinuationTokenMetadataKey = "__a2a__continuationToken"; - - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . - /// - /// Agent to attach A2A messaging processing capabilities to. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. - /// The store to store session contents and metadata. - /// Controls the response behavior of the agent run. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( - this AIAgent agent, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null, - JsonSerializerOptions? jsonSerializerOptions = null) - { - ArgumentNullException.ThrowIfNull(agent); - ArgumentNullException.ThrowIfNull(agent.Name); - - runMode ??= AgentRunMode.DisallowBackground; - - var hostAgent = new AIHostAgent( - innerAgent: agent, - sessionStore: agentSessionStore ?? new NoopAgentSessionStore()); - - taskManager ??= new TaskManager(); - - // Resolve the JSON serializer options for continuation token serialization. May be custom for the user's agent. - JsonSerializerOptions continuationTokenJsonOptions = jsonSerializerOptions ?? A2AHostingJsonUtilities.DefaultOptions; - - // OnMessageReceived handles both message-only and task-based flows. - // The A2A SDK prioritizes OnMessageReceived over OnTaskCreated when both are set, - // so we consolidate all initial message handling here and return either - // an AgentMessage or AgentTask depending on the agent response. - // When the agent returns a ContinuationToken (long-running operation), a task is - // created for stateful tracking. Otherwise a lightweight AgentMessage is returned. - // See https://github.com/a2aproject/a2a-dotnet/issues/275 - taskManager.OnMessageReceived += (p, ct) => OnMessageReceivedAsync(p, hostAgent, runMode, taskManager, continuationTokenJsonOptions, ct); - - // Task flow for subsequent updates and cancellations - taskManager.OnTaskUpdated += (t, ct) => OnTaskUpdatedAsync(t, hostAgent, taskManager, continuationTokenJsonOptions, ct); - taskManager.OnTaskCancelled += OnTaskCancelledAsync; - - return taskManager; - } - - /// - /// Attaches A2A (Agent2Agent) messaging capabilities via Message processing to the specified . - /// - /// Agent to attach A2A messaging processing capabilities to. - /// The agent card to return on query. - /// Instance of to configure for A2A messaging. New instance will be created if not passed. - /// The logger factory to use for creating instances. - /// The store to store session contents and metadata. - /// Controls the response behavior of the agent run. - /// Optional for serializing and deserializing continuation tokens. Use this when the agent's continuation token contains custom types not registered in the default options. Falls back to if not provided. - /// The configured . - public static ITaskManager MapA2A( - this AIAgent agent, - AgentCard agentCard, - ITaskManager? taskManager = null, - ILoggerFactory? loggerFactory = null, - AgentSessionStore? agentSessionStore = null, - AgentRunMode? runMode = null, - JsonSerializerOptions? jsonSerializerOptions = null) - { - taskManager = agent.MapA2A(taskManager, loggerFactory, agentSessionStore, runMode, jsonSerializerOptions); - - taskManager.OnAgentCardQuery += (context, query) => - { - // A2A SDK assigns the url on its own - // we can help user if they did not set Url explicitly. - if (string.IsNullOrEmpty(agentCard.Url)) - { - agentCard.Url = context.TrimEnd('/'); - } - - return Task.FromResult(agentCard); - }; - return taskManager; - } - - private static async Task OnMessageReceivedAsync( - MessageSendParams messageSendParams, - AIHostAgent hostAgent, - AgentRunMode runMode, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) - { - // AIAgent does not support resuming from arbitrary prior tasks. - // Throw explicitly so the client gets a clear error rather than a response - // that silently ignores the referenced task context. - // Follow-ups on the *same* task are handled via OnTaskUpdated instead. - if (messageSendParams.Message.ReferenceTaskIds is { Count: > 0 }) - { - throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context. Use OnTaskUpdated for follow-ups on the same task."); - } - - var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - - // Decide whether to run in background based on user preferences and agent capabilities - var decisionContext = new A2ARunDecisionContext(messageSendParams); - var allowBackgroundResponses = await runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false); - - var options = messageSendParams.Metadata is not { Count: > 0 } - ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } - : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() }; - - var response = await hostAgent.RunAsync( - messageSendParams.ToChatMessages(), - session: session, - options: options, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is null) - { - return CreateMessageFromResponse(contextId, response); - } - - var agentTask = await InitializeTaskAsync(contextId, messageSendParams.Message, taskManager, cancellationToken).ConfigureAwait(false); - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - return agentTask; - } - - private static async Task OnTaskUpdatedAsync( - AgentTask agentTask, - AIHostAgent hostAgent, - ITaskManager taskManager, - JsonSerializerOptions continuationTokenJsonOptions, - CancellationToken cancellationToken) - { - var contextId = agentTask.ContextId ?? Guid.NewGuid().ToString("N"); - var session = await hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); - - try - { - // Discard any stale continuation token — the incoming user message supersedes - // any previous background operation. AF agents don't support updating existing - // background responses (long-running operations); we start a fresh run from the - // existing session using the full chat history (which includes the new message). - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Working, cancellationToken: cancellationToken).ConfigureAwait(false); - - var response = await hostAgent.RunAsync( - ExtractChatMessagesFromTaskHistory(agentTask), - session: session, - options: new AgentRunOptions { AllowBackgroundResponses = true }, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); - - if (response.ContinuationToken is not null) - { - StoreContinuationToken(agentTask, response.ContinuationToken, continuationTokenJsonOptions); - await TransitionToWorkingAsync(agentTask.Id, contextId, response, taskManager, cancellationToken).ConfigureAwait(false); - } - else - { - await CompleteWithArtifactAsync(agentTask.Id, response, taskManager, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception) - { - await taskManager.UpdateStatusAsync( - agentTask.Id, - TaskState.Failed, - final: true, - cancellationToken: cancellationToken).ConfigureAwait(false); - throw; - } - } - - private static Task OnTaskCancelledAsync(AgentTask agentTask, CancellationToken cancellationToken) - { - // Remove the continuation token from metadata if present. - // The task has already been marked as cancelled by the TaskManager. - agentTask.Metadata?.Remove(ContinuationTokenMetadataKey); - return Task.CompletedTask; - } - - private static AgentMessage CreateMessageFromResponse(string contextId, AgentResponse response) => - new() - { - MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - ContextId = contextId, - Role = MessageRole.Agent, - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - // Task outputs should be returned as artifacts rather than messages: - // https://a2a-protocol.org/latest/specification/#37-messages-and-artifacts - private static Artifact CreateArtifactFromResponse(AgentResponse response) => - new() - { - ArtifactId = response.ResponseId ?? Guid.NewGuid().ToString("N"), - Parts = response.Messages.ToParts(), - Metadata = response.AdditionalProperties?.ToA2AMetadata() - }; - - private static async Task InitializeTaskAsync( - string contextId, - AgentMessage originalMessage, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - AgentTask agentTask = await taskManager.CreateTaskAsync(contextId, cancellationToken: cancellationToken).ConfigureAwait(false); - - // Add the original user message to the task history. - // The A2A SDK does this internally when it creates tasks via OnTaskCreated. - agentTask.History ??= []; - agentTask.History.Add(originalMessage); - - // Notify subscribers of the Submitted state per the A2A spec: https://a2a-protocol.org/latest/specification/#413-taskstate - await taskManager.UpdateStatusAsync(agentTask.Id, TaskState.Submitted, cancellationToken: cancellationToken).ConfigureAwait(false); - - return agentTask; - } - - private static void StoreContinuationToken( - AgentTask agentTask, - ResponseContinuationToken token, - JsonSerializerOptions continuationTokenJsonOptions) - { - // Serialize the continuation token into the task's metadata so it survives - // across requests and is cleaned up with the task itself. - agentTask.Metadata ??= []; - agentTask.Metadata[ContinuationTokenMetadataKey] = JsonSerializer.SerializeToElement( - token, - continuationTokenJsonOptions.GetTypeInfo(typeof(ResponseContinuationToken))); - } - - private static async Task TransitionToWorkingAsync( - string taskId, - string contextId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - // Include any intermediate progress messages from the response as a status message. - AgentMessage? progressMessage = response.Messages.Count > 0 ? CreateMessageFromResponse(contextId, response) : null; - await taskManager.UpdateStatusAsync(taskId, TaskState.Working, message: progressMessage, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - private static async Task CompleteWithArtifactAsync( - string taskId, - AgentResponse response, - ITaskManager taskManager, - CancellationToken cancellationToken) - { - var artifact = CreateArtifactFromResponse(response); - await taskManager.ReturnArtifactAsync(taskId, artifact, cancellationToken).ConfigureAwait(false); - await taskManager.UpdateStatusAsync(taskId, TaskState.Completed, final: true, cancellationToken: cancellationToken).ConfigureAwait(false); - } - - private static List ExtractChatMessagesFromTaskHistory(AgentTask agentTask) - { - if (agentTask.History is not { Count: > 0 }) - { - return []; - } - - var chatMessages = new List(agentTask.History.Count); - foreach (var message in agentTask.History) - { - chatMessages.Add(message.ToChatMessage()); - } - - return chatMessages; - } -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs index 087df96aae..3abb90afb6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/AgentRunMode.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.DiagnosticIds; @@ -28,7 +29,7 @@ public sealed class AgentRunMode : IEquatable } /// - /// Dissallows the background responses from the agent. Is equivalent to configuring as false. + /// Disallows the background responses from the agent. Is equivalent to configuring as false. /// In the A2A protocol terminology will make responses be returned as AgentMessage. /// public static AgentRunMode DisallowBackground => new(MessageValue); @@ -79,18 +80,22 @@ public sealed class AgentRunMode : IEquatable } // No delegate provided — fall back to "message" behavior. - return ValueTask.FromResult(true); + return ValueTask.FromResult(false); } /// public bool Equals(AgentRunMode? other) => - other is not null && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase); + other is not null + && string.Equals(this._value, other._value, StringComparison.OrdinalIgnoreCase) + && ReferenceEquals(this._runInBackground, other._runInBackground); /// public override bool Equals(object? obj) => this.Equals(obj as AgentRunMode); /// - public override int GetHashCode() => StringComparer.OrdinalIgnoreCase.GetHashCode(this._value); + public override int GetHashCode() => HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(this._value), + RuntimeHelpers.GetHashCode(this._runInBackground)); /// public override string ToString() => this._value; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs index 5d2381a235..b2f57fc09e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs @@ -31,21 +31,21 @@ internal static class MessageConverter return parts; } /// - /// Converts A2A MessageSendParams to a collection of Microsoft.Extensions.AI ChatMessage objects. + /// Converts A2A SendMessageRequest to a collection of Microsoft.Extensions.AI ChatMessage objects. /// - /// The A2A message send parameters to convert. + /// The A2A send message request to convert. /// A read-only collection of ChatMessage objects. - public static List ToChatMessages(this MessageSendParams messageSendParams) + public static List ToChatMessages(this SendMessageRequest sendMessageRequest) { - if (messageSendParams is null) + if (sendMessageRequest is null) { return []; } var result = new List(); - if (messageSendParams.Message?.Parts is not null) + if (sendMessageRequest.Message?.Parts is not null) { - result.Add(messageSendParams.Message.ToChatMessage()); + result.Add(sendMessageRequest.Message.ToChatMessage()); } return result; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs index 514922dd26..f8d855b5a3 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AAgentTests.cs @@ -6,9 +6,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Http; -using System.Net.ServerSentEvents; using System.Text; -using System.Text.Encodings.Web; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -57,6 +55,21 @@ public sealed class A2AAgentTests : IDisposable // Act & Assert Assert.Throws(() => new A2AAgent(null!)); + [Fact] + public void Constructor_WithIA2AClient_InitializesCorrectly() + { + // Arrange + IA2AClient ia2aClient = this._a2aClient; + + // Act + var agent = new A2AAgent(ia2aClient, "ia2a-id", "IA2A Agent", "An agent from IA2AClient"); + + // Assert + Assert.Equal("ia2a-id", agent.Id); + Assert.Equal("IA2A Agent", agent.Name); + Assert.Equal("An agent from IA2AClient", agent.Description); + } + [Fact] public void Constructor_WithDefaultParameters_UsesBaseProperties() { @@ -89,14 +102,17 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithValidUserMessage_RunsSuccessfullyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Hello! How can I help you today?" } - ] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Hello! How can I help you today?" } + ] + } }; var inputMessages = new List @@ -108,11 +124,11 @@ public sealed class A2AAgentTests : IDisposable var result = await this._agent.RunAsync(inputMessages); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, world!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, world!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.NotNull(result); @@ -120,8 +136,8 @@ public sealed class A2AAgentTests : IDisposable Assert.Equal("response-123", result.ResponseId); Assert.NotNull(result.RawRepresentation); - Assert.IsType(result.RawRepresentation); - Assert.Equal("response-123", ((AgentMessage)result.RawRepresentation).MessageId); + Assert.IsType(result.RawRepresentation); + Assert.Equal("response-123", ((Message)result.RawRepresentation).MessageId); Assert.Single(result.Messages); Assert.Equal(ChatRole.Assistant, result.Messages[0].Role); @@ -133,15 +149,18 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithNewSession_UpdatesSessionConversationIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "new-context-id" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "new-context-id" + } }; var inputMessages = new List @@ -177,7 +196,7 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync(inputMessages, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -191,15 +210,18 @@ public sealed class A2AAgentTests : IDisposable new(ChatRole.User, "Test message") }; - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = - [ - new TextPart { Text = "Response" } - ], - ContextId = "different-context" + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = + [ + new Part { Text = "Response" } + ], + ContextId = "different-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -219,12 +241,15 @@ public sealed class A2AAgentTests : IDisposable new(ChatRole.User, "Hello, streaming!") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Hello" }], - ContextId = "stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Hello" }], + ContextId = "stream-context" + } }; // Act @@ -238,11 +263,11 @@ public sealed class A2AAgentTests : IDisposable Assert.Single(updates); // Assert input message sent to A2AClient - var inputMessage = this._handler.CapturedMessageSendParams?.Message; + var inputMessage = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(inputMessage); Assert.Single(inputMessage.Parts); - Assert.Equal(MessageRole.User, inputMessage.Role); - Assert.Equal("Hello, streaming!", ((TextPart)inputMessage.Parts[0]).Text); + Assert.Equal(Role.User, inputMessage.Role); + Assert.Equal("Hello, streaming!", inputMessage.Parts[0].Text); // Assert response from A2AClient is converted correctly Assert.Equal(ChatRole.Assistant, updates[0].Role); @@ -251,8 +276,8 @@ public sealed class A2AAgentTests : IDisposable Assert.Equal(this._agent.Id, updates[0].AgentId); Assert.Equal("stream-1", updates[0].ResponseId); Assert.Equal(ChatFinishReason.Stop, updates[0].FinishReason); - Assert.IsType(updates[0].RawRepresentation); - Assert.Equal("stream-1", ((AgentMessage)updates[0].RawRepresentation!).MessageId); + Assert.IsType(updates[0].RawRepresentation); + Assert.Equal("stream-1", ((Message)updates[0].RawRepresentation!).MessageId); } [Fact] @@ -264,12 +289,15 @@ public sealed class A2AAgentTests : IDisposable new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var session = await this._agent.CreateSessionAsync(); @@ -294,7 +322,7 @@ public sealed class A2AAgentTests : IDisposable new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage(); + this._handler.StreamingResponseToReturn = new StreamResponse { Message = new Message() }; var session = await this._agent.CreateSessionAsync(); var a2aSession = (A2AAgentSession)session; @@ -307,7 +335,7 @@ public sealed class A2AAgentTests : IDisposable } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal("existing-context-id", message.ContextId); } @@ -325,12 +353,15 @@ public sealed class A2AAgentTests : IDisposable new(ChatRole.User, "Test streaming") }; - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "different-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "different-context" + } }; // Act @@ -346,12 +377,15 @@ public sealed class A2AAgentTests : IDisposable public async Task RunStreamingAsync_AllowsNonUserRoleMessagesAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage() + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-1", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], - ContextId = "new-stream-context" + Message = new Message + { + MessageId = "stream-1", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }], + ContextId = "new-stream-context" + } }; var inputMessages = new List @@ -385,13 +419,13 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync(inputMessages); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.NotNull(message); Assert.Equal(2, message.Parts.Count); - Assert.IsType(message.Parts[0]); - Assert.Equal("Check this file:", ((TextPart)message.Parts[0]).Text); - Assert.IsType(message.Parts[1]); - Assert.Equal("https://example.com/file.pdf", ((FilePart)message.Parts[1]).File.Uri?.ToString()); + Assert.Equal(PartContentCase.Text, message.Parts[0].ContentCase); + Assert.Equal("Check this file:", message.Parts[0].Text); + Assert.Equal(PartContentCase.Url, message.Parts[1].ContentCase); + Assert.Equal("https://example.com/file.pdf", message.Parts[1].Url); } [Fact] @@ -413,10 +447,11 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithContinuationToken_CallsGetTaskAsyncAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.AgentTaskToReturn = new AgentTask { Id = "task-123", - ContextId = "context-123" + ContextId = "context-123", + Status = new() { State = TaskState.Submitted } }; var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-123") }; @@ -425,19 +460,22 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync([], options: options); // Assert - Assert.Equal("tasks/get", this._handler.CapturedJsonRpcRequest?.Method); - Assert.Equal("task-123", this._handler.CapturedTaskIdParams?.Id); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequest?.Method); + Assert.Equal("task-123", this._handler.CapturedGetTaskRequest?.Id); } [Fact] public async Task RunAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response to task" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response to task" }] + } }; var session = (A2AAgentSession)await this._agent.CreateSessionAsync(); @@ -449,7 +487,7 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync(inputMessage, session); // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -459,11 +497,14 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -480,16 +521,19 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithAgentTaskResponse_ReturnsTaskResponseCorrectlyAsync() { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-789", - ContextId = "context-456", - Status = new() { State = TaskState.Submitted }, - Metadata = new Dictionary + Task = new AgentTask + { + Id = "task-789", + ContextId = "context-456", + Status = new() { State = TaskState.Submitted }, + Metadata = new Dictionary { { "key1", JsonSerializer.SerializeToElement("value1") }, { "count", JsonSerializer.SerializeToElement(42) } } + } }; var session = await this._agent.CreateSessionAsync(); @@ -532,11 +576,14 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithVariousTaskStates_ReturnsCorrectTokenAsync(TaskState taskState) { // Arrange - this._handler.ResponseToReturn = new AgentTask + this._handler.ResponseToReturn = new SendMessageResponse { - Id = "task-123", - ContextId = "context-123", - Status = new() { State = taskState } + Task = new AgentTask + { + Id = "task-123", + ContextId = "context-123", + Status = new() { State = taskState } + } }; // Act @@ -583,15 +630,200 @@ public sealed class A2AAgentTests : IDisposable }); } + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_UsesSubscribeToTaskMethodAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken("task-456") }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify SubscribeToTask was called (not SendStreamingMessage) + Assert.Single(this._handler.CapturedJsonRpcRequests); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_PassesCorrectTaskIdAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Continuation response" }] + } + }; + + const string ExpectedTaskId = "my-task-789"; + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(ExpectedTaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - verify the task ID was passed correctly + Assert.NotEmpty(this._handler.CapturedJsonRpcRequests); + var subscribeRequest = this._handler.CapturedJsonRpcRequests[0]; + var subscribeParams = subscribeRequest.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + Assert.NotNull(subscribeParams); + Assert.Equal(ExpectedTaskId, subscribeParams.Id); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_FallsBackToGetTaskAsync() + { + // Arrange + const string TaskId = "completed-task-123"; + const string ContextId = "ctx-completed"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed }, + Artifacts = + [ + new() { ArtifactId = "art-1", Parts = [new Part { Text = "Final result" }] } + ] + }; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + var updates = new List(); + await foreach (var update in this._agent.RunStreamingAsync([], null, options)) + { + updates.Add(update); + } + + // Assert - should yield one update from GetTaskAsync fallback + Assert.Single(updates); + var update0 = updates[0]; + Assert.Equal(TaskId, update0.ResponseId); + Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(TaskId, ((AgentTask)update0.RawRepresentation!).Id); + + // Assert - both SubscribeToTask and GetTask were called + Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithUnsupportedOperation_UpdatesSessionAsync() + { + // Arrange + const string TaskId = "completed-task-456"; + const string ContextId = "ctx-completed-456"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.AgentTaskToReturn = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Completed } + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync([], session, options)) + { + // Just iterate through to trigger the logic + } + + // Assert - session should be updated with the task state from GetTaskAsync + var a2aSession = (A2AAgentSession)session; + Assert.Equal(ContextId, a2aSession.ContextId); + Assert.Equal(TaskId, a2aSession.TaskId); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeFailsWithNonUnsupportedError_PropagatesWithoutFallbackAsync() + { + // Arrange + const string TaskId = "error-task-123"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.TaskNotFound; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act & Assert - the A2AException should propagate directly without fallback to GetTask + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + } + }); + + Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); + + // Assert - only SubscribeToTask was called, no fallback to GetTask + Assert.Single(this._handler.CapturedJsonRpcRequests); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + } + + [Fact] + public async Task RunStreamingAsync_WithContinuationToken_WhenSubscribeAndGetTaskBothFail_PropagatesExceptionAsync() + { + // Arrange + const string TaskId = "failed-task-789"; + + this._handler.StreamingErrorCodeToReturn = A2AErrorCode.UnsupportedOperation; + this._handler.GetTaskErrorCodeToReturn = A2AErrorCode.TaskNotFound; + + var options = new AgentRunOptions { ContinuationToken = new A2AContinuationToken(TaskId) }; + + // Act & Assert - the A2AException from GetTaskAsync should propagate to the caller + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var _ in this._agent.RunStreamingAsync([], null, options)) + { + } + }); + + Assert.Equal(A2AErrorCode.TaskNotFound, exception.ErrorCode); + + // Assert - both SubscribeToTask and GetTask were called + Assert.Equal(2, this._handler.CapturedJsonRpcRequests.Count); + Assert.Equal("SubscribeToTask", this._handler.CapturedJsonRpcRequests[0].Method); + Assert.Equal("GetTask", this._handler.CapturedJsonRpcRequests[1].Method); + } + [Fact] public async Task RunStreamingAsync_WithTaskInSessionAndMessage_AddTaskAsReferencesToMessageAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response to task" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response to task" }] + } }; var session = (A2AAgentSession)await this._agent.CreateSessionAsync(); @@ -604,7 +836,7 @@ public sealed class A2AAgentTests : IDisposable } // Assert - var message = this._handler.CapturedMessageSendParams?.Message; + var message = this._handler.CapturedSendMessageRequest?.Message; Assert.Null(message?.TaskId); Assert.NotNull(message?.ReferenceTaskIds); Assert.Contains("task-123", message.ReferenceTaskIds); @@ -614,11 +846,14 @@ public sealed class A2AAgentTests : IDisposable public async Task RunStreamingAsync_WithAgentTask_UpdatesSessionTaskIdAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = "task-456", - ContextId = "context-789", - Status = new() { State = TaskState.Submitted } + Task = new AgentTask + { + Id = "task-456", + ContextId = "context-789", + Status = new() { State = TaskState.Submitted } + } }; var session = await this._agent.CreateSessionAsync(); @@ -642,15 +877,18 @@ public sealed class A2AAgentTests : IDisposable const string ContextId = "ctx-456"; const string MessageText = "Hello from agent!"; - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = MessageId, - Role = MessageRole.Agent, - ContextId = ContextId, - Parts = - [ - new TextPart { Text = MessageText } - ] + Message = new Message + { + MessageId = MessageId, + Role = Role.Agent, + ContextId = ContextId, + Parts = + [ + new Part { Text = MessageText } + ] + } }; // Act @@ -670,8 +908,8 @@ public sealed class A2AAgentTests : IDisposable Assert.Equal(this._agent.Id, update0.AgentId); Assert.Equal(MessageText, update0.Text); Assert.Equal(ChatFinishReason.Stop, update0.FinishReason); - Assert.IsType(update0.RawRepresentation); - Assert.Equal(MessageId, ((AgentMessage)update0.RawRepresentation!).MessageId); + Assert.IsType(update0.RawRepresentation); + Assert.Equal(MessageId, ((Message)update0.RawRepresentation!).MessageId); } [Fact] @@ -681,18 +919,21 @@ public sealed class A2AAgentTests : IDisposable const string TaskId = "task-789"; const string ContextId = "ctx-012"; - this._handler.StreamingResponseToReturn = new AgentTask + this._handler.StreamingResponseToReturn = new StreamResponse { - Id = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Submitted }, - Artifacts = [ + Task = new AgentTask + { + Id = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Submitted }, + Artifacts = [ new() { ArtifactId = "art-123", - Parts = [new TextPart { Text = "Task artifact content" }] + Parts = [new Part { Text = "Task artifact content" }] } ] + } }; var session = await this._agent.CreateSessionAsync(); @@ -728,11 +969,14 @@ public sealed class A2AAgentTests : IDisposable const string TaskId = "task-status-123"; const string ContextId = "ctx-status-456"; - this._handler.StreamingResponseToReturn = new TaskStatusUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Status = new() { State = TaskState.Working } + StatusUpdate = new TaskStatusUpdateEvent + { + TaskId = TaskId, + ContextId = ContextId, + Status = new() { State = TaskState.Working } + } }; var session = await this._agent.CreateSessionAsync(); @@ -768,14 +1012,17 @@ public sealed class A2AAgentTests : IDisposable const string ContextId = "ctx-artifact-456"; const string ArtifactContent = "Task artifact data"; - this._handler.StreamingResponseToReturn = new TaskArtifactUpdateEvent + this._handler.StreamingResponseToReturn = new StreamResponse { - TaskId = TaskId, - ContextId = ContextId, - Artifact = new() + ArtifactUpdate = new TaskArtifactUpdateEvent { - ArtifactId = "artifact-789", - Parts = [new TextPart { Text = ArtifactContent }] + TaskId = TaskId, + ContextId = ContextId, + Artifact = new() + { + ArtifactId = "artifact-789", + Parts = [new Part { Text = ArtifactContent }] + } } }; @@ -848,15 +1095,18 @@ public sealed class A2AAgentTests : IDisposable public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response with metadata" }], - Metadata = new Dictionary + Message = new Message { - { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, - { "responseCount", JsonSerializer.SerializeToElement(99) } + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response with metadata" }], + Metadata = new Dictionary + { + { "responseKey1", JsonSerializer.SerializeToElement("responseValue1") }, + { "responseCount", JsonSerializer.SerializeToElement(99) } + } } }; @@ -877,14 +1127,17 @@ public sealed class A2AAgentTests : IDisposable } [Fact] - public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -906,22 +1159,25 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString()); - Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32()); - Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("value1", this._handler.CapturedSendMessageRequest.Metadata["key1"].GetString()); + Assert.Equal(42, this._handler.CapturedSendMessageRequest.Metadata["key2"].GetInt32()); + Assert.True(this._handler.CapturedSendMessageRequest.Metadata["key3"].GetBoolean()); } [Fact] public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.ResponseToReturn = new AgentMessage + this._handler.ResponseToReturn = new SendMessageResponse { - MessageId = "response-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }] + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Response" }] + } }; var inputMessages = new List @@ -938,19 +1194,22 @@ public sealed class A2AAgentTests : IDisposable await this._agent.RunAsync(inputMessages, null, options); // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); } [Fact] - public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync() + public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToSendMessageRequestAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -974,22 +1233,25 @@ public sealed class A2AAgentTests : IDisposable } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata); - Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString()); - Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32()); - Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean()); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Metadata); + Assert.Equal("streamValue1", this._handler.CapturedSendMessageRequest.Metadata["streamKey1"].GetString()); + Assert.Equal(100, this._handler.CapturedSendMessageRequest.Metadata["streamKey2"].GetInt32()); + Assert.False(this._handler.CapturedSendMessageRequest.Metadata["streamKey3"].GetBoolean()); } [Fact] public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync() { // Arrange - this._handler.StreamingResponseToReturn = new AgentMessage + this._handler.StreamingResponseToReturn = new StreamResponse { - MessageId = "stream-123", - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Streaming response" }] + Message = new Message + { + MessageId = "stream-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } }; var inputMessages = new List @@ -1008,8 +1270,115 @@ public sealed class A2AAgentTests : IDisposable } // Assert - Assert.NotNull(this._handler.CapturedMessageSendParams); - Assert.Null(this._handler.CapturedMessageSendParams.Metadata); + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Metadata); + } + + [Fact] + public async Task RunAsync_WithDefaultOptions_SetsBlockingToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesTrue_SetsReturnImmediatelyToTrueAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var session = await this._agent.CreateSessionAsync(); + var options = new AgentRunOptions { AllowBackgroundResponses = true }; + + // Act + await this._agent.RunAsync(inputMessages, session, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.True(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithAllowBackgroundResponsesFalse_SetsReturnImmediatelyToFalseAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + var options = new AgentRunOptions { AllowBackgroundResponses = false }; + + // Act + await this._agent.RunAsync(inputMessages, null, options); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunAsync_WithNullOptions_SetsReturnImmediatelyToFalseAsync() + { + // Arrange + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await this._agent.RunAsync(inputMessages, null, null); + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.NotNull(this._handler.CapturedSendMessageRequest.Configuration); + Assert.False(this._handler.CapturedSendMessageRequest.Configuration.ReturnImmediately); + } + + [Fact] + public async Task RunStreamingAsync_SendMessageRequest_DoesNotSetReturnImmediatelyConfigurationAsync() + { + // Arrange + this._handler.StreamingResponseToReturn = new StreamResponse + { + Message = new Message + { + MessageId = "response-123", + Role = Role.Agent, + Parts = [new Part { Text = "Streaming response" }] + } + }; + + var inputMessages = new List + { + new(ChatRole.User, "Test message") + }; + + // Act + await foreach (var _ in this._agent.RunStreamingAsync(inputMessages)) + { + // Just iterate through to trigger the logic + } + + // Assert + Assert.NotNull(this._handler.CapturedSendMessageRequest); + Assert.Null(this._handler.CapturedSendMessageRequest.Configuration); } [Fact] @@ -1042,17 +1411,31 @@ public sealed class A2AAgentTests : IDisposable #region GetService Method Tests /// - /// Verify that GetService returns A2AClient when requested. + /// Verify that GetService returns IA2AClient when requested. /// [Fact] - public void GetService_RequestingA2AClient_ReturnsA2AClient() + public void GetService_RequestingIA2AClient_ReturnsA2AClient() + { + // Arrange & Act + var result = this._agent.GetService(typeof(IA2AClient)); + + // Assert + Assert.NotNull(result); + Assert.Same(this._a2aClient, result); + } + + /// + /// Verify that GetService returns null when requesting the concrete A2AClient type + /// since the agent now exposes IA2AClient instead. + /// + [Fact] + public void GetService_RequestingConcreteA2AClient_ReturnsNull() { // Arrange & Act var result = this._agent.GetService(typeof(A2AClient)); // Assert - Assert.NotNull(result); - Assert.Same(this._a2aClient, result); + Assert.Null(result); } /// @@ -1129,10 +1512,10 @@ public sealed class A2AAgentTests : IDisposable /// Verify that GetService calls base.GetService() first but continues to derived logic when base returns null. /// [Fact] - public void GetService_RequestingA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() + public void GetService_RequestingIA2AClientWithServiceKey_CallsBaseFirstThenDerivedLogic() { - // Arrange & Act - Request A2AClient with a service key (base.GetService will return null due to serviceKey) - var result = this._agent.GetService(typeof(A2AClient), "some-key"); + // Arrange & Act - Request IA2AClient with a service key (base.GetService will return null due to serviceKey) + var result = this._agent.GetService(typeof(IA2AClient), "some-key"); // Assert Assert.NotNull(result); @@ -1256,6 +1639,7 @@ public sealed class A2AAgentTests : IDisposable public void Dispose() { + this._a2aClient.Dispose(); this._handler.Dispose(); this._httpClient.Dispose(); } @@ -1269,13 +1653,34 @@ public sealed class A2AAgentTests : IDisposable { public JsonRpcRequest? CapturedJsonRpcRequest { get; set; } - public MessageSendParams? CapturedMessageSendParams { get; set; } + public List CapturedJsonRpcRequests { get; } = []; - public TaskIdParams? CapturedTaskIdParams { get; set; } + public SendMessageRequest? CapturedSendMessageRequest { get; set; } - public A2AEvent? ResponseToReturn { get; set; } + public GetTaskRequest? CapturedGetTaskRequest { get; set; } - public A2AEvent? StreamingResponseToReturn { get; set; } + public SendMessageResponse? ResponseToReturn { get; set; } + + public AgentTask? AgentTaskToReturn { get; set; } + + public StreamResponse? StreamingResponseToReturn { get; set; } + + /// + /// When set, streaming requests for SubscribeToTask will return a JSON-RPC error + /// with this error code. Used to simulate UnsupportedOperation errors. + /// + public A2AErrorCode? StreamingErrorCodeToReturn { get; set; } + + /// + /// Error message to include when is set. + /// + public string StreamingErrorMessage { get; set; } = "Task is in a terminal state and cannot be subscribed to."; + + /// + /// When set, GetTask requests will return a JSON-RPC error with this error code. + /// Used to simulate failures in the GetTaskAsync fallback path. + /// + public A2AErrorCode? GetTaskErrorCodeToReturn { get; set; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { @@ -1286,46 +1691,121 @@ public sealed class A2AAgentTests : IDisposable this.CapturedJsonRpcRequest = JsonSerializer.Deserialize(content); - try + if (this.CapturedJsonRpcRequest is not null) { - this.CapturedMessageSendParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedJsonRpcRequests.Add(this.CapturedJsonRpcRequest); } - catch { /* Ignore deserialization errors for non-MessageSendParams requests */ } try { - this.CapturedTaskIdParams = this.CapturedJsonRpcRequest?.Params?.Deserialize(); + this.CapturedSendMessageRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); } - catch { /* Ignore deserialization errors for non-TaskIdParams requests */ } + catch { /* Ignore deserialization errors for non-SendMessageRequest requests */ } - // Return the pre-configured non-streaming response - if (this.ResponseToReturn is not null) + try { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", this.ResponseToReturn); + this.CapturedGetTaskRequest = this.CapturedJsonRpcRequest?.Params?.Deserialize(A2AJsonUtilities.DefaultOptions); + } + catch { /* Ignore deserialization errors for non-GetTaskRequest requests */ } + + // Return a JSON-RPC error for GetTask when configured + if (this.GetTaskErrorCodeToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.GetTaskErrorCodeToReturn.Value, + Message = "Simulated GetTask error." + } + }; return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") }; } + + // Return the pre-configured AgentTask response (for tasks/get) + if (this.AgentTaskToReturn is not null && this.CapturedJsonRpcRequest?.Method == "GetTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.AgentTaskToReturn, A2AJsonUtilities.DefaultOptions) + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + + // Return the pre-configured non-streaming response + if (this.ResponseToReturn is not null) + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.ResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + }; + } + // Return a streaming JSON-RPC error (e.g., UnsupportedOperation for SubscribeToTask) + else if (this.StreamingErrorCodeToReturn is not null + && this.CapturedJsonRpcRequest?.Method is "SubscribeToTask") + { + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Error = new JsonRpcError + { + Code = (int)this.StreamingErrorCodeToReturn.Value, + Message = this.StreamingErrorMessage + } + }; + + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + } + + stream.Position = 0; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(stream) + { + Headers = { { "Content-Type", "text/event-stream" } } + } + }; + } // Return the pre-configured streaming response else if (this.StreamingResponseToReturn is not null) { - var stream = new MemoryStream(); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(this.StreamingResponseToReturn, A2AJsonUtilities.DefaultOptions) + }; - await SseFormatter.WriteAsync( - new SseItem[] - { - new(JsonRpcResponse.CreateJsonRpcResponse("response-id", this.StreamingResponseToReturn!)) - }.ToAsyncEnumerable(), - stream, - (item, writer) => - { - using Utf8JsonWriter json = new(writer, new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - JsonSerializer.Serialize(json, item.Data); - }, - cancellationToken - ); + var stream = new MemoryStream(); + using (var writer = new StreamWriter(stream, Encoding.UTF8, leaveOpen: true)) + { + await writer.WriteAsync($"data: {JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions)}\n\n"); +#pragma warning disable CA2016 // Forward the 'CancellationToken' parameter to methods; overload doesn't exist downlevel + await writer.FlushAsync(); +#pragma warning restore CA2016 + } stream.Position = 0; @@ -1339,7 +1819,11 @@ public sealed class A2AAgentTests : IDisposable } else { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", new AgentMessage()); + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(new SendMessageResponse { Message = new Message() }, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs index 1bb0d99e00..30d65b12f1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/A2AContinuationTokenTests.cs @@ -106,6 +106,18 @@ public sealed class A2AContinuationTokenTests Assert.Throws(() => A2AContinuationToken.FromToken(emptyToken)); } + [Fact] + public void FromToken_WithNullTaskIdValue_ThrowsJsonException() + { + // Arrange + var jsonWithNullTaskId = System.Text.Encoding.UTF8.GetBytes("{ \"taskId\": null }").AsMemory(); + var mockToken = new MockResponseContinuationToken(jsonWithNullTaskId); + + // Act & Assert + var ex = Assert.Throws(() => A2AContinuationToken.FromToken(mockToken)); + Assert.Contains("taskId", ex.Message); + } + [Fact] public void FromToken_WithMissingTaskIdProperty_ThrowsException() { diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs index 358bdfb152..c2e704833a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAIContentExtensionsTests.cs @@ -42,14 +42,14 @@ public sealed class A2AAIContentExtensionsTests Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file1.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file1.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } [Fact] @@ -72,14 +72,14 @@ public sealed class A2AAIContentExtensionsTests Assert.NotNull(result); Assert.Equal(3, result.Count); - var firstTextPart = Assert.IsType(result[0]); - Assert.Equal("First text", firstTextPart.Text); + Assert.Equal(PartContentCase.Text, result[0].ContentCase); + Assert.Equal("First text", result[0].Text); - var filePart = Assert.IsType(result[1]); - Assert.Equal("https://example.com/file.txt", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, result[1].ContentCase); + Assert.Equal("https://example.com/file.txt", result[1].Url); - var secondTextPart = Assert.IsType(result[2]); - Assert.Equal("Second text", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, result[2].ContentCase); + Assert.Equal("Second text", result[2].Text); } // Mock class for testing unsupported scenarios diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs index f644109b38..f709e95ea7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs @@ -26,7 +26,7 @@ public sealed class A2AAgentCardExtensionsTests { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }; } @@ -50,13 +50,13 @@ public sealed class A2AAgentCardExtensionsTests using var handler = new HttpMessageHandlerStub(); using var httpClient = new HttpClient(handler, false); - handler.ResponsesToReturn.Enqueue(new AgentMessage + handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); - var agent = this._agentCard.AsAIAgent(httpClient); + var agent = this._agentCard.AsAIAgent(httpClient: httpClient); // Act await agent.RunAsync("Test input"); @@ -66,6 +66,105 @@ public sealed class A2AAgentCardExtensionsTests Assert.Equal(new Uri("http://test-endpoint/agent"), handler.CapturedUris[0]); } + [Fact] + public async Task AsAIAgent_WithPreferredBindings_UsesMatchingInterfaceAsync() + { + // Arrange + var card = new AgentCard + { + Name = "Multi-Interface Agent", + Description = "An agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://first/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://second/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, + ] + }; + + using var handler = new HttpMessageHandlerStub(); + using var httpClient = new HttpClient(handler, false); + + handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = card.AsAIAgent(httpClient, options: options); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Single(handler.CapturedUris); + Assert.Equal(new Uri("http://second/agent"), handler.CapturedUris[0]); + } + + [Fact] + public void AsAIAgent_WithNullOptions_UsesDefaultBindingPreference() + { + // Arrange + var card = new AgentCard + { + Name = "Default Options Agent", + Description = "Tests default A2AClientOptions behavior", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://default/agent" }, + ] + }; + + // Act - null options should use defaults (HTTP+JSON first, JSON-RPC as fallback) + var agent = card.AsAIAgent(options: null); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal("Default Options Agent", agent.Name); + } + + [Fact] + public void AsAIAgent_WithNoMatchingBinding_ThrowsException() + { + // Arrange + var card = new AgentCard + { + Name = "Unmatched Binding Agent", + Description = "Agent with unsupported binding only", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://grpc/agent", ProtocolBinding = "GRPC" }, + ] + }; + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + // Act & Assert - factory should throw when no matching binding exists + Assert.ThrowsAny(() => card.AsAIAgent(options: options)); + } + + [Fact] + public void AsAIAgent_WithNoSupportedInterfaces_ThrowsException() + { + // Arrange + var card = new AgentCard + { + Name = "No Interfaces Agent", + Description = "Agent with no supported interfaces", + }; + + // Act & Assert + Assert.ThrowsAny(() => card.AsAIAgent()); + } + internal sealed class HttpMessageHandlerStub : HttpMessageHandler { public Queue ResponsesToReturn { get; } = new(); @@ -86,13 +185,18 @@ public sealed class A2AAgentCardExtensionsTests Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs index 97c9ca7c05..5fdfb1ff89 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentTaskExtensionsTests.cs @@ -40,7 +40,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -58,7 +58,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -76,7 +76,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = [], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -94,7 +94,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = null, - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -110,14 +110,14 @@ public sealed class A2AAgentTaskExtensionsTests // Arrange var artifact = new Artifact { - Parts = [new TextPart { Text = "response" }], + Parts = [Part.FromText("response")], }; var agentTask = new AgentTask { Id = "task1", Artifacts = [artifact], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act @@ -136,15 +136,15 @@ public sealed class A2AAgentTaskExtensionsTests // Arrange var artifact1 = new Artifact { - Parts = [new TextPart { Text = "content1" }], + Parts = [Part.FromText("content1")], }; var artifact2 = new Artifact { Parts = [ - new TextPart { Text = "content2" }, - new TextPart { Text = "content3" } + Part.FromText("content2"), + Part.FromText("content3") ], }; @@ -152,7 +152,7 @@ public sealed class A2AAgentTaskExtensionsTests { Id = "task1", Artifacts = [artifact1, artifact2], - Status = new AgentTaskStatus { State = TaskState.Completed }, + Status = new TaskStatus { State = TaskState.Completed }, }; // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs index b18abd4485..1f6cfa65f0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AArtifactExtensionsTests.cs @@ -22,9 +22,9 @@ public sealed class A2AArtifactExtensionsTests Name = "comprehensive-artifact", Parts = [ - new TextPart { Text = "First part" }, - new TextPart { Text = "Second part" }, - new TextPart { Text = "Third part" } + Part.FromText("First part"), + Part.FromText("Second part"), + Part.FromText("Third part") ], Metadata = new Dictionary { @@ -66,9 +66,9 @@ public sealed class A2AArtifactExtensionsTests Name = "test", Parts = [ - new TextPart { Text = "Part 1" }, - new TextPart { Text = "Part 2" }, - new TextPart { Text = "Part 3" } + Part.FromText("Part 1"), + Part.FromText("Part 2"), + Part.FromText("Part 3") ], Metadata = null }; diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs index dcc45e8fce..8a664b7fc9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2ACardResolverExtensionsTests.cs @@ -37,7 +37,7 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable { Name = "Test Agent", Description = "A test agent for unit testing", - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); // Act @@ -60,15 +60,15 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable // Arrange this._handler.ResponsesToReturn.Enqueue(new AgentCard { - Url = "http://test-endpoint/agent" + SupportedInterfaces = [new AgentInterface { Url = "http://test-endpoint/agent" }] }); - this._handler.ResponsesToReturn.Enqueue(new AgentMessage + this._handler.ResponsesToReturn.Enqueue(new Message { - Role = MessageRole.Agent, - Parts = [new TextPart { Text = "Response" }], + Role = Role.Agent, + Parts = [Part.FromText("Response")], }); - var agent = await this._resolver.GetAIAgentAsync(this._httpClient); + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient); // Act await agent.RunAsync("Test input"); @@ -78,6 +78,41 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable Assert.Equal(new Uri("http://test-endpoint/agent"), this._handler.CapturedUris[1]); } + [Fact] + public async Task GetAIAgentAsync_WithOptions_PassesOptionsToFactoryAsync() + { + // Arrange + this._handler.ResponsesToReturn.Enqueue(new AgentCard + { + Name = "Options Agent", + Description = "Agent with multiple interfaces", + SupportedInterfaces = + [ + new AgentInterface { Url = "http://httpjson/agent", ProtocolBinding = ProtocolBindingNames.HttpJson }, + new AgentInterface { Url = "http://jsonrpc/agent", ProtocolBinding = ProtocolBindingNames.JsonRpc }, + ] + }); + this._handler.ResponsesToReturn.Enqueue(new Message + { + Role = Role.Agent, + Parts = [Part.FromText("Response")], + }); + + var options = new A2AClientOptions + { + PreferredBindings = [ProtocolBindingNames.JsonRpc] + }; + + var agent = await this._resolver.GetAIAgentAsync(httpClient: this._httpClient, options: options); + + // Act + await agent.RunAsync("Test input"); + + // Assert + Assert.Equal(2, this._handler.CapturedUris.Count); + Assert.Equal(new Uri("http://jsonrpc/agent"), this._handler.CapturedUris[1]); + } + public void Dispose() { this._handler.Dispose(); @@ -104,13 +139,18 @@ public sealed class A2ACardResolverExtensionsTests : IDisposable Content = new StringContent(json, Encoding.UTF8, "application/json") }; } - else if (response is AgentMessage message) + else if (response is Message message) { - var jsonRpcResponse = JsonRpcResponse.CreateJsonRpcResponse("response-id", message); + var sendMessageResponse = new SendMessageResponse { Message = message }; + var jsonRpcResponse = new JsonRpcResponse + { + Id = "response-id", + Result = JsonSerializer.SerializeToNode(sendMessageResponse, A2AJsonUtilities.DefaultOptions) + }; return new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse), Encoding.UTF8, "application/json") + Content = new StringContent(JsonSerializer.Serialize(jsonRpcResponse, A2AJsonUtilities.DefaultOptions), Encoding.UTF8, "application/json") }; } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs index 9ad4d982a9..80b5107bf1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AClientExtensionsTests.cs @@ -30,4 +30,40 @@ public sealed class A2AClientExtensionsTests Assert.Equal(TestName, agent.Name); Assert.Equal(TestDescription, agent.Description); } + + [Fact] + public void GetAIAgent_WithIA2AClient_ReturnsA2AAgentWithSpecifiedProperties() + { + // Arrange - use IA2AClient reference type to verify the extension method works with the interface + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + const string TestId = "ia2a-agent-id"; + const string TestName = "IA2A Agent"; + const string TestDescription = "Agent created from IA2AClient"; + + // Act + var agent = a2aClient.AsAIAgent(TestId, TestName, TestDescription); + + // Assert + Assert.NotNull(agent); + Assert.IsType(agent); + Assert.Equal(TestId, agent.Id); + Assert.Equal(TestName, agent.Name); + Assert.Equal(TestDescription, agent.Description); + } + + [Fact] + public void GetAIAgent_WithIA2AClient_ExposesClientViaGetService() + { + // Arrange + IA2AClient a2aClient = new A2AClient(new Uri("http://test-endpoint")); + + // Act + var agent = a2aClient.AsAIAgent(); + + // Assert + var service = agent.GetService(typeof(IA2AClient)); + Assert.NotNull(service); + Assert.Same(a2aClient, service); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs index 8d771c679c..bb502bbea0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/ChatMessageExtensionsTests.cs @@ -32,20 +32,19 @@ public sealed class ChatMessageExtensionsTests Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } [Fact] @@ -71,19 +70,18 @@ public sealed class ChatMessageExtensionsTests Assert.NotNull(a2aMessage.MessageId); Assert.NotEmpty(a2aMessage.MessageId); - Assert.Equal(MessageRole.User, a2aMessage.Role); + Assert.Equal(Role.User, a2aMessage.Role); Assert.NotNull(a2aMessage.Parts); Assert.Equal(3, a2aMessage.Parts.Count); - var filePart = Assert.IsType(a2aMessage.Parts[0]); - Assert.NotNull(filePart.File); - Assert.Equal("https://example.com/report.pdf", filePart.File.Uri?.ToString()); + Assert.Equal(PartContentCase.Url, a2aMessage.Parts[0].ContentCase); + Assert.Equal("https://example.com/report.pdf", a2aMessage.Parts[0].Url); - var secondTextPart = Assert.IsType(a2aMessage.Parts[1]); - Assert.Equal("please summarize the file content", secondTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[1].ContentCase); + Assert.Equal("please summarize the file content", a2aMessage.Parts[1].Text); - var thirdTextPart = Assert.IsType(a2aMessage.Parts[2]); - Assert.Equal("and send it to me over email", thirdTextPart.Text); + Assert.Equal(PartContentCase.Text, a2aMessage.Parts[2].ContentCase); + Assert.Equal("and send it to me over email", a2aMessage.Parts[2].Text); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj index d33de0613b..97541f6a94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(TargetFrameworksCore) + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs new file mode 100644 index 0000000000..6558144184 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -0,0 +1,966 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.AI; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AAgentHandlerTests +{ + /// + /// Verifies that when metadata is null, the options passed to RunAsync have + /// AllowBackgroundResponses disabled and no AdditionalProperties. + /// + [Fact] + public async Task ExecuteAsync_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options)); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); + Assert.Null(capturedOptions.AdditionalProperties); + } + + /// + /// Verifies that when metadata is non-empty, the options passed to RunAsync have + /// AdditionalProperties populated with the converted metadata values. + /// + [Fact] + public async Task ExecuteAsync_WhenMetadataIsNonEmpty_PassesOptionsWithAdditionalPropertiesToRunAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options)); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }, + Metadata = new Dictionary + { + ["key1"] = JsonSerializer.SerializeToElement("value1"), + ["key2"] = JsonSerializer.SerializeToElement(42) + } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); + Assert.NotNull(capturedOptions.AdditionalProperties); + Assert.Equal(2, capturedOptions.AdditionalProperties.Count); + Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString()); + } + + /// + /// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values. + /// + [Fact] + public async Task ExecuteAsync_WhenResponseHasAdditionalProperties_ReturnsMessageWithMetadataAsync() + { + // Arrange + AdditionalPropertiesDictionary additionalProps = new() + { + ["responseKey1"] = "responseValue1", + ["responseKey2"] = 123 + }; + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = additionalProps + }; + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.Metadata); + Assert.Equal(2, message.Metadata.Count); + Assert.True(message.Metadata.ContainsKey("responseKey1")); + Assert.True(message.Metadata.ContainsKey("responseKey2")); + } + + /// + /// Verifies that when the agent response has null AdditionalProperties, the returned Message.Metadata is null. + /// + [Fact] + public async Task ExecuteAsync_WhenResponseHasNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = null + }; + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Null(message.Metadata); + } + + /// + /// Verifies that when the agent response has empty AdditionalProperties, the returned Message.Metadata is null. + /// + [Fact] + public async Task ExecuteAsync_WhenResponseHasEmptyAdditionalProperties_ReturnsMessageWithNullMetadataAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) + { + AdditionalProperties = [] + }; + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Null(message.Metadata); + } + + /// + /// Verifies that when runMode is DisallowBackground, AllowBackgroundResponses is false. + /// + [Fact] + public async Task ExecuteAsync_DisallowBackgroundMode_SetsAllowBackgroundResponsesFalseAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.DisallowBackground); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); + } + + /// + /// Verifies that in AllowBackgroundIfSupported mode, AllowBackgroundResponses is true. + /// + [Fact] + public async Task ExecuteAsync_AllowBackgroundIfSupportedMode_SetsAllowBackgroundResponsesTrueAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundIfSupported); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + } + + /// + /// Verifies that a custom Dynamic delegate returning false sets AllowBackgroundResponses to false. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_WithFalseCallback_SetsAllowBackgroundResponsesFalseAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.False(capturedOptions.AllowBackgroundResponses); + } + + /// + /// Verifies that a custom Dynamic delegate returning true sets AllowBackgroundResponses to true. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_WithTrueCallback_SetsAllowBackgroundResponsesTrueAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true))); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + } + +#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + + /// + /// Verifies that when the agent returns a ContinuationToken, task status events are emitted. + /// + [Fact] + public async Task ExecuteAsync_WhenResponseHasContinuationToken_EmitsTaskStatusEventsAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) + { + ContinuationToken = CreateTestContinuationToken() + }; + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", + ContextId = "ctx-1", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert - should have emitted status update events (Submitted + Working) + Assert.True(events.StatusUpdates.Count >= 1); + Assert.Empty(events.Messages); + } + + /// + /// Verifies that when the incoming message has a ContextId, it is used for the response + /// rather than generating a new one. + /// + [Fact] + public async Task ExecuteAsync_WhenMessageHasContextId_UsesProvidedContextIdAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "my-context-123", + Message = new Message + { + MessageId = "test-id", + ContextId = "my-context-123", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("my-context-123", message.ContextId); + } + + /// + /// Verifies that on continuation when the agent completes (no ContinuationToken), task is completed with artifact. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenComplete_EmitsArtifactAndCompletedAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }); + + // Assert - should have artifact + completed status + Assert.True(events.ArtifactUpdates.Count > 0); + Assert.True(events.StatusUpdates.Count > 0); + Assert.Empty(events.Messages); + } + + /// + /// Verifies that when the agent throws during a continuation, + /// the handler emits a Failed status and re-throws the exception. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenAgentThrows_EmitsFailedStatusAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => + throw new InvalidOperationException("Agent failed")); + A2AAgentHandler handler = CreateHandler(agentMock); + + // Act & Assert + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + CancellationToken.None)); + eventQueue.Complete(null); + await readerTask; + + // Assert - should have emitted Failed status + Assert.True(events.StatusUpdates.Count > 0); + } + + /// + /// Verifies that when the agent throws during a continuation and the cancellation token + /// is already cancelled, the handler still emits a Failed status and re-throws the + /// original exception (not an OperationCanceledException from FailAsync). + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenAgentThrowsWithCancelledToken_StillEmitsFailedStatusAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => + throw new InvalidOperationException("Agent failed")); + A2AAgentHandler handler = CreateHandler(agentMock); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); // Pre-cancel the token + + // Act & Assert - the original InvalidOperationException should be thrown, not OperationCanceledException + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + cts.Token)); + eventQueue.Complete(null); + await readerTask; + + // Assert - should have emitted Failed status even with a cancelled token + Assert.True(events.StatusUpdates.Count > 0); + } + + /// + /// Verifies that when the agent throws OperationCanceledException during a continuation, + /// no Failed status is emitted. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenOperationCancelled_DoesNotEmitFailedAsync() + { + // Arrange + int callCount = 0; + Mock agentMock = CreateAgentMockWithCallCount(ref callCount, _ => + throw new OperationCanceledException("Cancelled")); + A2AAgentHandler handler = CreateHandler(agentMock); + + // Act & Assert + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + CancellationToken.None)); + eventQueue.Complete(null); + await readerTask; + + // Assert - should NOT have emitted any status (OperationCanceledException is re-thrown without marking Failed) + Assert.Empty(events.StatusUpdates); + } + + /// + /// Verifies that ReferenceTaskIds throws NotSupportedException. + /// + [Fact] + public async Task ExecuteAsync_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync() + { + // Arrange + A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { })); + + // Act & Assert + await Assert.ThrowsAsync(() => + InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }], + ReferenceTaskIds = ["other-task-id"] + } + })); + } + + /// + /// Verifies that when ContextId is null, a new one is generated and used in the response. + /// + [Fact] + public async Task ExecuteAsync_WhenContextIdIsNull_GeneratesContextIdAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = null!, + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.ContextId); + Assert.NotEmpty(message.ContextId); + } + + /// + /// Verifies that when Message is null, the handler still succeeds with empty chat messages. + /// + [Fact] + public async Task ExecuteAsync_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx", + Message = null! + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("ctx", message.ContextId); + } + + /// + /// Verifies that the dynamic AllowBackgroundWhen delegate receives the correct RequestContext. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_DelegateReceivesRequestContextAsync() + { + // Arrange + A2ARunDecisionContext? capturedContext = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => { }), + runMode: AgentRunMode.AllowBackgroundWhen((ctx, _) => + { + capturedContext = ctx; + return ValueTask.FromResult(false); + })); + + var requestContext = new RequestContext + { + TaskId = "my-task", ContextId = "my-ctx", StreamingResponse = false, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }; + + // Act + await InvokeExecuteAsync(handler, requestContext); + + // Assert + Assert.NotNull(capturedContext); + Assert.Same(requestContext, capturedContext.RequestContext); + } + + /// + /// Verifies that CancelAsync emits a Canceled status event. + /// + [Fact] + public async Task CancelAsync_EmitsCanceledStatusAsync() + { + // Arrange + A2AAgentHandler handler = CreateHandler(CreateAgentMock(_ => { })); + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + + // Act + await handler.CancelAsync( + new RequestContext + { + StreamingResponse = false, + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + TaskId = "task-1", + ContextId = "ctx-1", + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1" } + }, + eventQueue, + CancellationToken.None); + + // Assert + eventQueue.Complete(null); + await readerTask; + Assert.True(events.StatusUpdates.Count > 0); + } + +#pragma warning restore MEAI001 + + /// + /// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore + /// and can execute successfully. + /// + [Fact] + public async Task Handler_WithNullSessionStore_UsesInMemorySessionStoreAndExecutesSuccessfullyAsync() + { + // Arrange + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: null); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("Reply", message.Parts![0].Text); + } + + /// + /// Verifies that when a custom session store is provided, it is used instead of the + /// default InMemoryAgentSessionStore. + /// + [Fact] + public async Task Handler_WithCustomSessionStore_UsesProvidedSessionStoreAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-1", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }); + + // Assert - verify the custom session store was called + mockSessionStore.Verify( + x => x.GetSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny()), + Times.Once); + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-1"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that when no session store is provided, the default InMemoryAgentSessionStore + /// persists sessions across multiple calls with the same context ID. + /// + [Fact] + public async Task Handler_WithNullSessionStore_SessionIsPersistedAcrossCallsAsync() + { + // Arrange - track how many times CreateSessionCoreAsync is called + int createSessionCallCount = 0; + var sessionInstance = new TestAgentSession(); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .Callback(() => Interlocked.Increment(ref createSessionCallCount)) + .ReturnsAsync(() => new TestAgentSession()); + agentMock + .Protected() + .Setup>("SerializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(JsonDocument.Parse("{}").RootElement); + agentMock + .Protected() + .Setup>("DeserializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(sessionInstance); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Reply")])); + + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: null); + + var context = new RequestContext + { + StreamingResponse = false, + TaskId = "", + ContextId = "ctx-persistent", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }; + + // Act - call twice with the same context ID + await InvokeExecuteAsync(handler, context); + await InvokeExecuteAsync(handler, context); + + // Assert - CreateSessionCoreAsync should be called once (first call creates, second retrieves from store) + Assert.Equal(1, createSessionCallCount); + } + + /// + /// Verifies that when the AllowBackgroundWhen delegate throws, the exception propagates + /// and the agent is not invoked. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_WhenCallbackThrows_PropagatesExceptionAsync() + { + // Arrange + bool agentInvoked = false; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => agentInvoked = true), + runMode: AgentRunMode.AllowBackgroundWhen((_, _) => + throw new InvalidOperationException("Callback failed"))); + + // Act & Assert + await Assert.ThrowsAsync(() => + InvokeExecuteAsync(handler, new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + })); + + Assert.False(agentInvoked); + } + + /// + /// Verifies that the CancellationToken is propagated to the AllowBackgroundWhen delegate. + /// + [Fact] + public async Task ExecuteAsync_DynamicMode_CancellationTokenIsPropagatedToCallbackAsync() + { + // Arrange + CancellationToken capturedToken = default; + using var cts = new CancellationTokenSource(); + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(_ => { }), + runMode: AgentRunMode.AllowBackgroundWhen((_, ct) => + { + capturedToken = ct; + return ValueTask.FromResult(false); + })); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + + /// + /// Verifies that the agent run mode is applied on the continuation/task-update path, + /// not just the new message path. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_RunModeIsAppliedAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler( + CreateAgentMock(options => capturedOptions = options), + runMode: AgentRunMode.AllowBackgroundIfSupported); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", + ContextId = "ctx-1", + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + + Task = new AgentTask { Id = "task-1", ContextId = "ctx-1", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.True(capturedOptions.AllowBackgroundResponses); + } + + private static A2AAgentHandler CreateHandler( + Mock agentMock, + AgentRunMode? runMode = null, + AgentSessionStore? agentSessionStore = null) + { + runMode ??= AgentRunMode.DisallowBackground; + + var hostAgent = new AIHostAgent( + innerAgent: agentMock.Object, + sessionStore: agentSessionStore ?? new InMemoryAgentSessionStore()); + + return new A2AAgentHandler(hostAgent, runMode); + } + + private static Mock CreateAgentMock(Action optionsCallback) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, _, options, _) => optionsCallback(options)) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); + + return agentMock; + } + + private static Mock CreateAgentMockWithResponse(AgentResponse response) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(response); + + return agentMock; + } + + private static Mock CreateAgentMockWithCallCount( + ref int callCount, + Func responseFactory) + { + StrongBox callCountBox = new(callCount); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(() => + { + int currentCall = Interlocked.Increment(ref callCountBox.Value); + return responseFactory(currentCall); + }); + + return agentMock; + } + + private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context) + { + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); + } + + private static async Task CollectEventsAsync(A2AAgentHandler handler, RequestContext context) + { + var events = new EventCollector(); + var eventQueue = new AgentEventQueue(); + var readerTask = ReadEventsAsync(eventQueue, events); + + await handler.ExecuteAsync(context, eventQueue, CancellationToken.None); + eventQueue.Complete(null); + await readerTask; + + return events; + } + + private static async Task ReadEventsAsync(AgentEventQueue eventQueue, EventCollector collector) + { + await foreach (var response in eventQueue) + { + switch (response.PayloadCase) + { + case StreamResponseCase.Message: + collector.Messages.Add(response.Message!); + break; + case StreamResponseCase.Task: + collector.Tasks.Add(response.Task!); + break; + case StreamResponseCase.StatusUpdate: + collector.StatusUpdates.Add(response.StatusUpdate!); + break; + case StreamResponseCase.ArtifactUpdate: + collector.ArtifactUpdates.Add(response.ArtifactUpdate!); + break; + } + } + } + +#pragma warning disable MEAI001 + private static ResponseContinuationToken CreateTestContinuationToken() + { + return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); + } +#pragma warning restore MEAI001 + + private sealed class EventCollector + { + public List Messages { get; } = []; + public List Tasks { get; } = []; + public List StatusUpdates { get; } = []; + public List ArtifactUpdates { get; } = []; + } + + private sealed class TestAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs new file mode 100644 index 0000000000..e5fa337e86 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AEndpointRouteBuilderExtensionsTests.cs @@ -0,0 +1,559 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Tests for A2AEndpointRouteBuilderExtensions and A2AServerServiceCollectionExtensions methods. +/// +public sealed class A2AEndpointRouteBuilderExtensionsTests +{ + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints. + /// + [Fact] + public void MapA2AHttpJson_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AHttpJson(agentBuilder, "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentBuilder. + /// + [Fact] + public void MapA2AHttpJson_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, "/a2a")); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson with IHostedAgentBuilder correctly maps the agent with default configuration. + /// + [Fact] + public void MapA2AHttpJson_WithAgentBuilder_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2AHttpJson with string agent name correctly maps the agent. + /// + [Fact] + public void MapA2AHttpJson_WithAgentName_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AHttpJson("agent", "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2AJsonRpc with IHostedAgentBuilder correctly maps the agent. + /// + [Fact] + public void MapA2AJsonRpc_WithAgentBuilder_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AJsonRpc(agentBuilder, "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2AJsonRpc with string agent name correctly maps the agent. + /// + [Fact] + public void MapA2AJsonRpc_WithAgentName_DefaultConfiguration_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddA2AServer("agent"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AJsonRpc("agent", "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that both MapA2AHttpJson and MapA2AJsonRpc can be called for the same agent. + /// + [Fact] + public void MapA2AHttpJson_And_MapA2AJsonRpc_SameAgent_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var httpResult = app.MapA2AHttpJson(agentBuilder, "/a2a"); + var rpcResult = app.MapA2AJsonRpc(agentBuilder, "/a2a"); + Assert.NotNull(httpResult); + Assert.NotNull(rpcResult); + } + + /// + /// Verifies that multiple agents can be mapped to different paths. + /// + [Fact] + public void MapA2AHttpJson_MultipleAgents_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); + IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); + agent1Builder.AddA2AServer(); + agent2Builder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2AHttpJson(agent1Builder, "/a2a/agent1"); + app.MapA2AHttpJson(agent2Builder, "/a2a/agent2"); + Assert.NotNull(app); + } + + /// + /// Verifies that custom paths can be specified for A2A endpoints. + /// + [Fact] + public void MapA2AHttpJson_WithCustomPath_AcceptsValidPath() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + app.MapA2AHttpJson(agentBuilder, "/custom/a2a/path"); + Assert.NotNull(app); + } + + /// + /// Verifies that AddA2AServer with custom A2AServerRegistrationOptions succeeds. + /// + [Fact] + public void AddA2AServer_WithCustomOptions_Succeeds() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(options => options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert - Should not throw + var result = app.MapA2AHttpJson(agentBuilder, "/a2a"); + Assert.NotNull(result); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null endpoints when using string agent name. + /// + [Fact] + public void MapA2AHttpJson_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AHttpJson("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null endpoints when using string agent name. + /// + [Fact] + public void MapA2AJsonRpc_WithAgentName_NullEndpoints_ThrowsArgumentNullException() + { + // Arrange + AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + endpoints.MapA2AJsonRpc("agent", "/a2a")); + + Assert.Equal("endpoints", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null agentName. + /// + [Fact] + public void MapA2AHttpJson_WithAgentName_NullAgentName_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson((string)null!, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentException for empty agentName. + /// + [Fact] + public void MapA2AHttpJson_WithAgentName_EmptyAgentName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AHttpJson(string.Empty, "/a2a")); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null path. + /// + [Fact] + public void MapA2AHttpJson_NullPath_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, null!)); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentException for whitespace-only path. + /// + [Fact] + public void MapA2AHttpJson_WhitespacePath_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + agentBuilder.AddA2AServer(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + Assert.Throws(() => + app.MapA2AHttpJson(agentBuilder, " ")); + } + + /// + /// Verifies that AddA2AServer throws ArgumentNullException for null services. + /// + [Fact] + public void AddA2AServer_NullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer("agent")); + + Assert.Equal("services", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer throws ArgumentNullException for null agentName. + /// + [Fact] + public void AddA2AServer_NullAgentName_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer((string)null!)); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer throws ArgumentException for empty agentName. + /// + [Fact] + public void AddA2AServer_EmptyAgentName_ThrowsArgumentException() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + services.AddA2AServer(string.Empty)); + + Assert.Equal("agentName", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer on IHostedAgentBuilder throws ArgumentNullException for null builder. + /// + [Fact] + public void AddA2AServer_NullAgentBuilder_ThrowsArgumentNullException() + { + // Arrange + IHostedAgentBuilder agentBuilder = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + agentBuilder.AddA2AServer()); + + Assert.Equal("agentBuilder", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for null AIAgent. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_NullAgent_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson(agent, "/a2a")); + + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentNullException for AIAgent with null Name. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_NullName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AHttpJson(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws ArgumentException for AIAgent with whitespace Name. + /// + [Fact] + public void MapA2AHttpJson_WithAIAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AHttpJson(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for null AIAgent. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_NullAgent_ThrowsArgumentNullException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + AIAgent agent = null!; + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agent, "/a2a")); + + Assert.Equal("agent", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentNullException for AIAgent with null Name. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_NullName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AJsonRpc throws ArgumentException for AIAgent with whitespace Name. + /// + [Fact] + public void MapA2AJsonRpc_WithAIAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + app.MapA2AJsonRpc(agentMock.Object, "/a2a")); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that MapA2AHttpJson throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. + /// + [Fact] + public void MapA2AHttpJson_WithoutAddA2AServer_ThrowsInvalidOperationException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AHttpJson("agent", "/a2a")); + + Assert.Contains("agent", exception.Message); + Assert.Contains("AddA2AServer", exception.Message); + } + + /// + /// Verifies that MapA2AJsonRpc throws InvalidOperationException when no A2AServer has been + /// registered for the specified agent via AddA2AServer. + /// + [Fact] + public void MapA2AJsonRpc_WithoutAddA2AServer_ThrowsInvalidOperationException() + { + // Arrange + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + IChatClient mockChatClient = new DummyChatClient(); + builder.Services.AddKeyedSingleton("chat-client", mockChatClient); + builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); + builder.Services.AddLogging(); + using WebApplication app = builder.Build(); + + // Act & Assert + InvalidOperationException exception = Assert.Throws(() => + app.MapA2AJsonRpc("agent", "/a2a")); + + Assert.Contains("agent", exception.Message); + Assert.Contains("AddA2AServer", exception.Message); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs deleted file mode 100644 index f8604c7eac..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AIntegrationTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Text.Json; -using System.Threading.Tasks; -using A2A; -using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting.Server; -using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -public sealed class A2AIntegrationTests -{ - /// - /// Verifies that calling the A2A card endpoint with MapA2A returns an agent card with a URL populated. - /// - [Fact] - public async Task MapA2A_WithAgentCard_CardEndpointReturnsCardWithUrlAsync() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.UseTestServer(); - - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("test-agent", "Test instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication", - Version = "1.0" - }; - - // Map A2A with the agent card - app.MapA2A(agentBuilder, "/a2a/test-agent", agentCard); - - await app.StartAsync(); - - try - { - // Get the test server client - TestServer testServer = app.Services.GetRequiredService() as TestServer - ?? throw new InvalidOperationException("TestServer not found"); - var httpClient = testServer.CreateClient(); - - // Act - Query the agent card endpoint - var requestUri = new Uri("/a2a/test-agent/v1/card", UriKind.Relative); - var response = await httpClient.GetAsync(requestUri); - - // Assert - Assert.True(response.IsSuccessStatusCode, $"Expected successful response but got {response.StatusCode}"); - - var content = await response.Content.ReadAsStringAsync(); - var jsonDoc = JsonDocument.Parse(content); - var root = jsonDoc.RootElement; - - // Verify the card has expected properties - Assert.True(root.TryGetProperty("name", out var nameProperty)); - Assert.Equal("Test Agent", nameProperty.GetString()); - - Assert.True(root.TryGetProperty("description", out var descProperty)); - Assert.Equal("A test agent for A2A communication", descProperty.GetString()); - - // Verify the card has a URL property and it's not null/empty - Assert.True(root.TryGetProperty("url", out var urlProperty)); - Assert.NotEqual(JsonValueKind.Null, urlProperty.ValueKind); - - var url = urlProperty.GetString(); - Assert.NotNull(url); - Assert.NotEmpty(url); - Assert.StartsWith("http", url, StringComparison.OrdinalIgnoreCase); - - // agentCard's URL matches the agent endpoint - Assert.Equal($"{testServer.BaseAddress.ToString().TrimEnd('/')}/a2a/test-agent", url); - } - finally - { - await app.StopAsync(); - } - } -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..aae07e8e6f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AServerServiceCollectionExtensionsTests.cs @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using A2A; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Moq.Protected; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class A2AServerServiceCollectionExtensionsTests +{ + /// + /// Verifies that AddA2AServer with an agent name registers a keyed A2AServer + /// that can be resolved from the service provider. + /// + [Fact] + public async Task AddA2AServer_WithAgentName_ResolvesKeyedA2AServerAsync() + { + // Arrange + const string AgentName = "test-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + // Act + services.AddA2AServer(AgentName); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that AddA2AServer with an agent instance registers a keyed A2AServer + /// that can be resolved from the service provider using the agent's name. + /// + [Fact] + public async Task AddA2AServer_WithAgentInstance_ResolvesKeyedA2AServerAsync() + { + // Arrange + const string AgentName = "instance-agent"; + var agentMock = CreateAgentMock(AgentName); + var services = new ServiceCollection(); + + // Act + services.AddA2AServer(agentMock.Object); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that when no ITaskStore or AgentSessionStore are registered, + /// AddA2AServer falls back to in-memory defaults and resolves successfully. + /// + [Fact] + public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync() + { + // Arrange + const string AgentName = "default-stores-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + // Act + services.AddA2AServer(AgentName); + + // Assert - resolution succeeds without any stores registered + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that when a custom ITaskStore is registered, AddA2AServer uses it + /// instead of the default InMemoryTaskStore. + /// + [Fact] + public async Task AddA2AServer_WithCustomTaskStore_ResolvesSuccessfullyAsync() + { + // Arrange + const string AgentName = "custom-taskstore-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockTaskStore = new Mock(); + services.AddKeyedSingleton(AgentName, mockTaskStore.Object); + + // Act + services.AddA2AServer(AgentName); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that when a custom AgentSessionStore is registered, AddA2AServer uses it + /// instead of the default InMemoryAgentSessionStore. + /// + [Fact] + public async Task AddA2AServer_WithCustomAgentSessionStore_ResolvesSuccessfullyAsync() + { + // Arrange + const string AgentName = "custom-sessionstore-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockSessionStore = new Mock(); + services.AddKeyedSingleton(AgentName, mockSessionStore.Object); + + // Act + services.AddA2AServer(AgentName); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that when a custom IAgentHandler is registered, AddA2AServer uses it + /// instead of creating a default A2AAgentHandler. + /// + [Fact] + public async Task AddA2AServer_WithCustomAgentHandler_ResolvesSuccessfullyAsync() + { + // Arrange + const string AgentName = "custom-handler-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockHandler = new Mock(); + services.AddKeyedSingleton(AgentName, mockHandler.Object); + + // Act + services.AddA2AServer(AgentName); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that the configureOptions callback is invoked when provided. + /// + [Fact] + public async Task AddA2AServer_WithConfigureOptions_InvokesCallbackAsync() + { + // Arrange + const string AgentName = "options-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + bool callbackInvoked = false; + + // Act + services.AddA2AServer(AgentName, options => + { + callbackInvoked = true; + options.AgentRunMode = AgentRunMode.AllowBackgroundIfSupported; + }); + + // Assert - callback is invoked during resolution + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + Assert.True(callbackInvoked); + } + + /// + /// Verifies that AddA2AServer with a null configureOptions does not throw. + /// + [Fact] + public async Task AddA2AServer_WithNullConfigureOptions_ResolvesSuccessfullyAsync() + { + // Arrange + const string AgentName = "null-options-agent"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + // Act + services.AddA2AServer(AgentName, configureOptions: null); + + // Assert + await using var provider = services.BuildServiceProvider(); + var server = provider.GetKeyedService(AgentName); + Assert.NotNull(server); + } + + /// + /// Verifies that AddA2AServer throws when the agent name is null. + /// + [Fact] + public void AddA2AServer_WithNullAgentName_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.ThrowsAny(() => services.AddA2AServer(agentName: null!)); + } + + /// + /// Verifies that AddA2AServer throws when the agent name is whitespace. + /// + [Fact] + public void AddA2AServer_WithWhitespaceAgentName_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.ThrowsAny(() => services.AddA2AServer(agentName: " ")); + } + + /// + /// Verifies that AddA2AServer throws when the services parameter is null. + /// + [Fact] + public void AddA2AServer_WithNullServices_ThrowsArgumentNullException() + { + // Arrange + IServiceCollection services = null!; + + // Act & Assert + Assert.Throws(() => services.AddA2AServer("agent")); + } + + /// + /// Verifies that AddA2AServer with an agent instance throws when the agent is null. + /// + [Fact] + public void AddA2AServer_WithNullAgent_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + + // Act & Assert + Assert.Throws(() => services.AddA2AServer(agent: null!)); + } + + /// + /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is null. + /// + [Fact] + public void AddA2AServer_WithAgent_NullName_ThrowsArgumentNullException() + { + // Arrange + var services = new ServiceCollection(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns((string?)null); + + // Act & Assert + ArgumentNullException exception = Assert.Throws(() => + services.AddA2AServer(agentMock.Object)); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that AddA2AServer with an agent instance throws when the agent's Name is whitespace. + /// + [Fact] + public void AddA2AServer_WithAgent_WhitespaceName_ThrowsArgumentException() + { + // Arrange + var services = new ServiceCollection(); + var agentMock = new Mock(); + agentMock.Setup(a => a.Name).Returns(" "); + + // Act & Assert + ArgumentException exception = Assert.Throws(() => + services.AddA2AServer(agentMock.Object)); + + Assert.Equal("agent.Name", exception.ParamName); + } + + /// + /// Verifies that when a custom is registered as a keyed service, + /// the uses it to process requests instead of the default handler. + /// + [Fact] + public async Task AddA2AServer_WithCustomHandler_CustomHandlerIsInvokedOnRequestAsync() + { + // Arrange + const string AgentName = "custom-handler-wiring"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockHandler = new Mock(); + mockHandler + .Setup(h => h.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((RequestContext _, AgentEventQueue eq, CancellationToken ct) => + eq.EnqueueMessageAsync( + new Message { MessageId = "resp", Role = Role.Agent, Parts = [new Part { Text = "Reply" }] }, ct).AsTask()); + + services.AddKeyedSingleton(AgentName, mockHandler.Object); + + services.AddA2AServer(AgentName); + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredKeyedService(AgentName); + + // Act + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token); + + // Assert - the custom handler was invoked, not the default A2AAgentHandler + mockHandler.Verify( + h => h.ExecuteAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); + Assert.NotNull(response.Message); + } + + /// + /// Verifies that when a custom is registered as a keyed service + /// and no custom is registered, the default handler uses the custom + /// session store for session management during request processing. + /// + [Fact] + public async Task AddA2AServer_WithCustomSessionStore_NoHandler_SessionStoreIsUsedOnRequestAsync() + { + // Arrange + const string AgentName = "custom-sessionstore-wiring"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMock(AgentName).Object); + + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + services.AddKeyedSingleton(AgentName, mockSessionStore.Object); + + services.AddA2AServer(AgentName); + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredKeyedService(AgentName); + + // Act + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token); + + // Assert - the custom session store was used, not InMemoryAgentSessionStore + mockSessionStore.Verify( + x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); + Assert.NotNull(response.Message); + } + + /// + /// Verifies that when no custom stores or handlers are registered, the server uses + /// the default in-memory stores and processes requests successfully end-to-end. + /// + [Fact] + public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync() + { + // Arrange + const string AgentName = "default-stores-request"; + var services = new ServiceCollection(); + services.AddKeyedSingleton(AgentName, (_, _) => CreateAgentMockForRequests(AgentName).Object); + + services.AddA2AServer(AgentName); + await using var provider = services.BuildServiceProvider(); + var server = provider.GetRequiredKeyedService(AgentName); + + // Act + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token); + + // Assert - request was processed successfully with default in-memory stores + Assert.NotNull(response); + Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase); + Assert.NotNull(response.Message); + } + + private static SendMessageRequest CreateTestSendMessageRequest() => + new() + { + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }] + } + }; + + private static Mock CreateAgentMock(string name) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns(name); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); + + return agentMock; + } + + /// + /// Creates a mock with session serialization support, suitable for + /// tests that exercise the full request processing path with . + /// + private static Mock CreateAgentMockForRequests(string name) + { + Mock agentMock = CreateAgentMock(name); + agentMock + .Protected() + .Setup>("SerializeSessionCoreAsync", + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(JsonDocument.Parse("{}").RootElement); + + return agentMock; + } + + private sealed class TestAgentSession : AgentSession; +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs deleted file mode 100644 index 87de6e52cd..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AIAgentExtensionsTests.cs +++ /dev/null @@ -1,866 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using A2A; -using Microsoft.Extensions.AI; -using Moq; -using Moq.Protected; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -/// -/// Unit tests for the class. -/// -public sealed class AIAgentExtensionsTests -{ - /// - /// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync have - /// AllowBackgroundResponses enabled and no AdditionalProperties. - /// - [Fact] - public async Task MapA2A_WhenMetadataIsNull_PassesOptionsWithNoAdditionalPropertiesToRunAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); - - // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = null - }); - - // Assert - Assert.NotNull(capturedOptions); - Assert.False(capturedOptions.AllowBackgroundResponses); - Assert.Null(capturedOptions.AdditionalProperties); - } - - /// - /// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values. - /// - [Fact] - public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); - - // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = new Dictionary - { - ["key1"] = JsonSerializer.SerializeToElement("value1"), - ["key2"] = JsonSerializer.SerializeToElement(42) - } - }); - - // Assert - Assert.NotNull(capturedOptions); - Assert.NotNull(capturedOptions.AdditionalProperties); - Assert.Equal(2, capturedOptions.AdditionalProperties.Count); - Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1")); - Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2")); - } - - /// - /// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync have - /// AllowBackgroundResponses enabled and no AdditionalProperties. - /// - [Fact] - public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesOptionsWithNoAdditionalPropertiesToRunAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A(); - - // Act - await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }, - Metadata = [] - }); - - // Assert - Assert.NotNull(capturedOptions); - Assert.False(capturedOptions.AllowBackgroundResponses); - Assert.Null(capturedOptions.AdditionalProperties); - } - - /// - /// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values. - /// - [Fact] - public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync() - { - // Arrange - AdditionalPropertiesDictionary additionalProps = new() - { - ["responseKey1"] = "responseValue1", - ["responseKey2"] = 123 - }; - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) - { - AdditionalProperties = additionalProps - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.NotNull(agentMessage.Metadata); - Assert.Equal(2, agentMessage.Metadata.Count); - Assert.True(agentMessage.Metadata.ContainsKey("responseKey1")); - Assert.True(agentMessage.Metadata.ContainsKey("responseKey2")); - Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString()); - Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32()); - } - - /// - /// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null. - /// - [Fact] - public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) - { - AdditionalProperties = null - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Null(agentMessage.Metadata); - } - - /// - /// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null. - /// - [Fact] - public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")]) - { - AdditionalProperties = [] - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Null(agentMessage.Metadata); - } - - /// - /// Verifies that when runMode is Message, the result is always an AgentMessage even when - /// the agent would otherwise support background responses. - /// - [Fact] - public async Task MapA2A_MessageMode_AlwaysReturnsAgentMessageAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.DisallowBackground); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - Assert.NotNull(capturedOptions); - Assert.False(capturedOptions.AllowBackgroundResponses); - } - - /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the result is an AgentMessage because the response type is determined solely by ContinuationToken presence. - /// - [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageAsync() - { - // Arrange - AgentRunOptions? capturedOptions = null; - ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - Assert.NotNull(capturedOptions); - Assert.True(capturedOptions.AllowBackgroundResponses); - } - - /// - /// Verifies that a custom Dynamic delegate returning false produces an AgentMessage - /// even when the agent completes immediately (no ContinuationToken). - /// - [Fact] - public async Task MapA2A_DynamicMode_WithFalseCallback_ReturnsAgentMessageAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Quick reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false))); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - Assert.IsType(a2aResponse); - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - - /// - /// Verifies that when the agent returns a ContinuationToken, an AgentTask in Working state is returned. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_ReturnsAgentTaskInWorkingStateAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - } - - /// - /// Verifies that when the agent returns a ContinuationToken, the returned task includes - /// intermediate messages from the initial response in its status message. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_TaskStatusHasIntermediateMessageAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Status.Message); - TextPart textPart = Assert.IsType(Assert.Single(agentTask.Status.Message.Parts)); - Assert.Equal("Starting work...", textPart.Text); - } - - /// - /// Verifies that when the agent returns a ContinuationToken, the continuation token - /// is serialized into the AgentTask.Metadata for persistence. - /// - [Fact] - public async Task MapA2A_WhenResponseHasContinuationToken_StoresTokenInTaskMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when a task is created (Working or Completed), the original user message - /// is added to the task history, matching the A2A SDK's behavior when it creates tasks internally. - /// - [Fact] - public async Task MapA2A_WhenTaskIsCreated_OriginalMessageIsInHistoryAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting work...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - AgentMessage originalMessage = new() { MessageId = "user-msg-1", Role = MessageRole.User, Parts = [new TextPart { Text = "Do something" }] }; - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = originalMessage - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.History); - Assert.Contains(agentTask.History, m => m.MessageId == "user-msg-1" && m.Role == MessageRole.User); - } - - /// - /// Verifies that in BackgroundIfSupported mode when the agent completes immediately (no ContinuationToken), - /// the returned AgentMessage preserves the original context ID. - /// - [Fact] - public async Task MapA2A_BackgroundIfSupportedMode_WhenNoContinuationToken_ReturnsAgentMessageWithContextIdAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - AgentMessage originalMessage = new() { MessageId = "user-msg-2", ContextId = "ctx-123", Role = MessageRole.User, Parts = [new TextPart { Text = "Quick task" }] }; - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = originalMessage - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("ctx-123", agentMessage.ContextId); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns a completed response (null ContinuationToken), the task is updated to Completed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationCompletes_TaskIsCompletedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken (long-running) - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): return completed response - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - - // Act — invoke OnTaskUpdated to check on the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert — task should now be completed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Artifact artifact = Assert.Single(updatedTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Done!", textPart.Text); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a task with a pending continuation token - /// and the agent returns another ContinuationToken, the task stays in Working state. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenBackgroundOperationStillWorking_TaskRemainsWorkingAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithSequentialResponses( - // First call: return response with ContinuationToken - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): still working, return another token - new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - ref callCount); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — trigger OnMessageReceived to create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — invoke OnTaskUpdated; agent still working - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert — task should still be in Working state - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); - } - - /// - /// Verifies the full lifecycle: agent starts background work, first poll returns still working, - /// second poll returns completed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_MultiplePolls_EventuallyCompletesAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch - { - // First call: start background work - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call: still working - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Still working...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Third call: done - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "All done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Do work" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - - // Act — first poll: still working - AgentTask? currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Working, currentTask.Status.State); - - // Act — second poll: completed - await InvokeOnTaskUpdatedAsync(taskManager, currentTask); - currentTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(currentTask); - Assert.Equal(TaskState.Completed, currentTask.Status.State); - - // Assert — final output as artifact - Assert.NotNull(currentTask.Artifacts); - Artifact artifact = Assert.Single(currentTask.Artifacts); - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("All done!", textPart.Text); - } - - /// - /// Verifies that when the agent throws during a background operation poll, - /// the task is updated to Failed state. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenAgentThrows_TaskIsFailedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) - { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new InvalidOperationException("Agent failed"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — poll the task; agent throws - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); - - // Assert — task should be Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Failed, updatedTask.Status.State); - } - - /// - /// Verifies that in Task mode with a ContinuationToken, the result is an AgentTask in Working state. - /// - [Fact] - public async Task MapA2A_TaskMode_WhenContinuationToken_ReturnsWorkingAgentTaskAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Working on it...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response) - .Object.MapA2A(runMode: AgentRunMode.AllowBackgroundIfSupported); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when the agent returns a ContinuationToken with no progress messages, - /// the task transitions to Working state with a null status message. - /// - [Fact] - public async Task MapA2A_WhenContinuationTokenWithNoMessages_TaskStatusHasNullMessageAsync() - { - // Arrange - AgentResponse response = new([]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - - // Assert - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.Equal(TaskState.Working, agentTask.Status.State); - Assert.Null(agentTask.Status.Message); - } - - /// - /// Verifies that when OnTaskUpdated is invoked on a completed task with a follow-up message - /// and no continuation token in metadata, the task processes history and completes with a new artifact. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenNoContinuationToken_ProcessesHistoryAndCompletesAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - return invocation switch - { - // First call: create a task with ContinuationToken - 1 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }, - // Second call (via OnTaskUpdated): complete the background operation - 2 => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done!")]), - // Third call (follow-up via OnTaskUpdated): complete follow-up - _ => new AgentResponse([new ChatMessage(ChatRole.Assistant, "Follow-up done!")]) - }; - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create a working task (with continuation token) - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — first OnTaskUpdated: completes the background operation - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - agentTask = (await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None))!; - Assert.Equal(TaskState.Completed, agentTask.Status.State); - - // Simulate a follow-up message by adding it to history and re-submitting via OnTaskUpdated - agentTask.History ??= []; - agentTask.History.Add(new AgentMessage { MessageId = "follow-up", Role = MessageRole.User, Parts = [new TextPart { Text = "Follow up" }] }); - - // Act — invoke OnTaskUpdated without a continuation token in metadata - await InvokeOnTaskUpdatedAsync(taskManager, agentTask); - - // Assert - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Completed, updatedTask.Status.State); - Assert.NotNull(updatedTask.Artifacts); - Assert.Equal(2, updatedTask.Artifacts.Count); - Artifact artifact = updatedTask.Artifacts[1]; - TextPart textPart = Assert.IsType(Assert.Single(artifact.Parts)); - Assert.Equal("Follow-up done!", textPart.Text); - } - - /// - /// Verifies that when a task is cancelled, the continuation token is removed from metadata. - /// - [Fact] - public async Task MapA2A_OnTaskCancelled_RemovesContinuationTokenFromMetadataAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act — create a working task with a continuation token - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - Assert.NotNull(agentTask.Metadata); - Assert.True(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - - // Act — cancel the task - await taskManager.CancelTaskAsync(new TaskIdParams { Id = agentTask.Id }, CancellationToken.None); - - // Assert — continuation token should be removed from metadata - Assert.False(agentTask.Metadata.ContainsKey("__a2a__continuationToken")); - } - - /// - /// Verifies that when the agent throws an OperationCanceledException during a poll, - /// it is re-thrown without marking the task as Failed. - /// - [Fact] - public async Task MapA2A_OnTaskUpdated_WhenOperationCancelled_DoesNotMarkFailedAsync() - { - // Arrange - int callCount = 0; - Mock agentMock = CreateAgentMockWithCallCount(ref callCount, invocation => - { - if (invocation == 1) - { - return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Starting...")]) - { - ContinuationToken = CreateTestContinuationToken() - }; - } - - throw new OperationCanceledException("Cancelled"); - }); - ITaskManager taskManager = agentMock.Object.MapA2A(); - - // Act — create the task - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] } - }); - AgentTask agentTask = Assert.IsType(a2aResponse); - - // Act — poll the task; agent throws OperationCanceledException - await Assert.ThrowsAsync(() => InvokeOnTaskUpdatedAsync(taskManager, agentTask)); - - // Assert — task should still be Working, not Failed - AgentTask? updatedTask = await taskManager.GetTaskAsync(new TaskQueryParams { Id = agentTask.Id }, CancellationToken.None); - Assert.NotNull(updatedTask); - Assert.Equal(TaskState.Working, updatedTask.Status.State); - } - - /// - /// Verifies that when the incoming message has a ContextId, it is used for the task - /// rather than generating a new one. - /// - [Fact] - public async Task MapA2A_WhenMessageHasContextId_UsesProvidedContextIdAsync() - { - // Arrange - AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); - ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A(); - - // Act - A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams - { - Message = new AgentMessage - { - MessageId = "test-id", - ContextId = "my-context-123", - Role = MessageRole.User, - Parts = [new TextPart { Text = "Hello" }] - } - }); - - // Assert - AgentMessage agentMessage = Assert.IsType(a2aResponse); - Assert.Equal("my-context-123", agentMessage.ContextId); - } - -#pragma warning restore MEAI001 - - private static Mock CreateAgentMock(Action optionsCallback) - { - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( - (_, _, options, _) => optionsCallback(options)) - .ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Test response")])); - - return agentMock; - } - - private static Mock CreateAgentMockWithResponse(AgentResponse response) - { - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(response); - - return agentMock; - } - - private static async Task InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams) - { - Func>? handler = taskManager.OnMessageReceived; - Assert.NotNull(handler); - return await handler.Invoke(messageSendParams, CancellationToken.None); - } - - private static async Task InvokeOnTaskUpdatedAsync(ITaskManager taskManager, AgentTask agentTask) - { - Func? handler = taskManager.OnTaskUpdated; - Assert.NotNull(handler); - await handler.Invoke(agentTask, CancellationToken.None); - } - -#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - private static ResponseContinuationToken CreateTestContinuationToken() - { - return ResponseContinuationToken.FromBytes(new byte[] { 0x01, 0x02, 0x03 }); - } -#pragma warning restore MEAI001 - - private static Mock CreateAgentMockWithSequentialResponses( - AgentResponse firstResponse, - AgentResponse secondResponse, - ref int callCount) - { - return CreateAgentMockWithCallCount(ref callCount, invocation => - invocation == 1 ? firstResponse : secondResponse); - } - - private static Mock CreateAgentMockWithCallCount( - ref int callCount, - Func responseFactory) - { - // Use a StrongBox to allow the lambda to capture a mutable reference - StrongBox callCountBox = new(callCount); - - Mock agentMock = new() { CallBase = true }; - agentMock.SetupGet(x => x.Name).Returns("TestAgent"); - agentMock - .Protected() - .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) - .ReturnsAsync(new TestAgentSession()); - agentMock - .Protected() - .Setup>("RunCoreAsync", - ItExpr.IsAny>(), - ItExpr.IsAny(), - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => - { - int currentCall = Interlocked.Increment(ref callCountBox.Value); - return responseFactory(currentCall); - }); - - return agentMock; - } - - private sealed class TestAgentSession : AgentSession; -} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs new file mode 100644 index 0000000000..cbe1254b81 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/AgentRunModeTests.cs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; + +/// +/// Unit tests for the class. +/// +public sealed class AgentRunModeTests +{ + /// + /// Verifies that AllowBackgroundWhen throws ArgumentNullException for null delegate. + /// + [Fact] + public void AllowBackgroundWhen_NullDelegate_ThrowsArgumentNullException() + { + // Arrange & Act & Assert + Assert.Throws(() => + AgentRunMode.AllowBackgroundWhen(null!)); + } + + /// + /// Verifies that DisallowBackground equals another DisallowBackground instance. + /// + [Fact] + public void Equals_DisallowBackground_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.DisallowBackground; + var mode2 = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } + + /// + /// Verifies that AllowBackgroundIfSupported equals another AllowBackgroundIfSupported instance. + /// + [Fact] + public void Equals_AllowBackgroundIfSupported_AreEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundIfSupported; + var mode2 = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + } + + /// + /// Verifies that DisallowBackground and AllowBackgroundIfSupported are not equal. + /// + [Fact] + public void Equals_DifferentModes_AreNotEqual() + { + // Arrange + var disallow = AgentRunMode.DisallowBackground; + var allow = AgentRunMode.AllowBackgroundIfSupported; + + // Act & Assert + Assert.False(disallow.Equals(allow)); + Assert.False(disallow == allow); + Assert.True(disallow != allow); + } + + /// + /// Verifies that Equals returns false for null. + /// + [Fact] + public void Equals_Null_ReturnsFalse() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.False(mode.Equals(null)); + Assert.False(mode.Equals((object?)null)); + Assert.False(mode == null); + Assert.True(mode != null); + } + + /// + /// Verifies that two null AgentRunMode values are equal. + /// + [Fact] + public void Equals_BothNull_AreEqual() + { + // Arrange + AgentRunMode? mode1 = null; + AgentRunMode? mode2 = null; + + // Act & Assert + Assert.True(mode1 == mode2); + Assert.False(mode1 != mode2); + } + + /// + /// Verifies that ToString returns expected values. + /// + [Fact] + public void ToString_ReturnsExpectedValues() + { + // Act & Assert + Assert.Equal("message", AgentRunMode.DisallowBackground.ToString()); + Assert.Equal("task", AgentRunMode.AllowBackgroundIfSupported.ToString()); + Assert.Equal("dynamic", AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)).ToString()); + } + + /// + /// Verifies that Equals works correctly with object parameter. + /// + [Fact] + public void Equals_WithObjectParameter_WorksCorrectly() + { + // Arrange + var mode = AgentRunMode.DisallowBackground; + + // Act & Assert + Assert.True(mode.Equals((object)AgentRunMode.DisallowBackground)); + Assert.False(mode.Equals((object)AgentRunMode.AllowBackgroundIfSupported)); + Assert.False(mode.Equals("not a run mode")); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with different delegates are not considered equal, + /// because equality includes delegate identity for dynamic modes. + /// + [Fact] + public void Equals_AllowBackgroundWhen_DifferentDelegates_AreNotEqual() + { + // Arrange + var mode1 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(true)); + var mode2 = AgentRunMode.AllowBackgroundWhen((_, _) => ValueTask.FromResult(false)); + + // Act & Assert + Assert.False(mode1.Equals(mode2)); + Assert.True(mode1 != mode2); + } + + /// + /// Verifies that two AllowBackgroundWhen instances with the same delegate are considered equal. + /// + [Fact] + public void Equals_AllowBackgroundWhen_SameDelegate_AreEqual() + { + // Arrange + static ValueTask CallbackAsync(A2ARunDecisionContext _, CancellationToken __) => ValueTask.FromResult(true); + var mode1 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); + var mode2 = AgentRunMode.AllowBackgroundWhen(CallbackAsync); + + // Act & Assert + Assert.True(mode1.Equals(mode2)); + Assert.True(mode1 == mode2); + Assert.Equal(mode1.GetHashCode(), mode2.GetHashCode()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index 69eaf3a535..e26189e9e5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Linq; using A2A; using Microsoft.Agents.AI.Hosting.A2A.Converters; @@ -10,66 +11,66 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters; public class MessageConverterTests { [Fact] - public void ToChatMessages_MessageSendParams_Null_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_Null_ReturnsEmptyCollection() { - MessageSendParams? messageSendParams = null; + SendMessageRequest? sendMessageRequest = null; - var result = messageSendParams!.ToChatMessages(); + var result = sendMessageRequest!.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithNullMessage_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithNullMessage_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { Message = null! }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithMessageWithoutParts_ReturnsEmptyCollection() + public void ToChatMessages_SendMessageRequest_WithMessageWithoutParts_ReturnsEmptyCollection() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = null! } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Empty(result); } [Fact] - public void ToChatMessages_MessageSendParams_WithValidTextMessage_ReturnsCorrectChatMessage() + public void ToChatMessages_SendMessageRequest_WithValidTextMessage_ReturnsCorrectChatMessage() { - var messageSendParams = new MessageSendParams + var sendMessageRequest = new SendMessageRequest { - Message = new AgentMessage + Message = new Message { MessageId = "test-id", - Role = MessageRole.User, + Role = Role.User, Parts = [ - new TextPart { Text = "Hello, world!" } + new Part { Text = "Hello, world!" } ] } }; - var result = messageSendParams.ToChatMessages(); + var result = sendMessageRequest.ToChatMessages(); Assert.NotNull(result); Assert.Single(result); @@ -82,4 +83,68 @@ public class MessageConverterTests var textContent = Assert.IsType(chatMessage.Contents.First()); Assert.Equal("Hello, world!", textContent.Text); } + + [Fact] + public void ToParts_NullList_ReturnsEmptyList() + { + // Arrange + IList? messages = null; + + // Act + var result = messages!.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_EmptyList_ReturnsEmptyList() + { + // Arrange + IList messages = []; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_WithTextContent_ReturnsTextPart() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.Assistant, "Hello from the agent!") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Single(result); + Assert.Equal("Hello from the agent!", result[0].Text); + } + + [Fact] + public void ToParts_WithMultipleMessages_ReturnsAllParts() + { + // Arrange + IList messages = + [ + new ChatMessage(ChatRole.User, "First message"), + new ChatMessage(ChatRole.Assistant, "Second message") + ]; + + // Act + var result = messages.ToParts(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First message", result[0].Text); + Assert.Equal("Second message", result[1].Text); + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs deleted file mode 100644 index a848528888..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/EndpointRouteA2ABuilderExtensionsTests.cs +++ /dev/null @@ -1,479 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using A2A; -using Microsoft.Agents.AI.Hosting.A2A.UnitTests.Internal; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests; - -/// -/// Tests for MicrosoftAgentAIHostingA2AEndpointRouteBuilderExtensions.MapA2A method. -/// -public sealed class EndpointRouteA2ABuilderExtensionsTests -{ - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints. - /// - [Fact] - public void MapA2A_WithAgentBuilder_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(agentBuilder, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null agentBuilder. - /// - [Fact] - public void MapA2A_WithAgentBuilder_NullAgentBuilder_ThrowsArgumentNullException() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - IHostedAgentBuilder agentBuilder = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - app.MapA2A(agentBuilder, "/a2a")); - - Assert.Equal("agentBuilder", exception.ParamName); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder correctly maps the agent with default task manager configuration. - /// - [Fact] - public void MapA2A_WithAgentBuilder_DefaultConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with IHostedAgentBuilder, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentBuilder_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using string agent name. - /// - [Fact] - public void MapA2A_WithAgentName_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A("agent", "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with string agent name correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAgentName_DefaultConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with string agent name, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAgentName_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A("agent", "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using AIAgent. - /// - [Fact] - public void MapA2A_WithAIAgent_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A((AIAgent)null!, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that MapA2A with AIAgent correctly maps the agent. - /// - [Fact] - public void MapA2A_WithAIAgent_DefaultConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a"); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_CustomTaskManagerConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent and agent card succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_WithAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A with AIAgent, agent card, and custom task manager configuration succeeds. - /// - [Fact] - public void MapA2A_WithAIAgent_WithAgentCardAndCustomConfiguration_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - AIAgent agent = app.Services.GetRequiredKeyedService("agent"); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A test agent for A2A communication" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agent, "/a2a", agentCard, taskManager => { }); - Assert.NotNull(result); - Assert.NotNull(app); - } - - /// - /// Verifies that MapA2A throws ArgumentNullException for null endpoints when using ITaskManager. - /// - [Fact] - public void MapA2A_WithTaskManager_NullEndpoints_ThrowsArgumentNullException() - { - // Arrange - AspNetCore.Routing.IEndpointRouteBuilder endpoints = null!; - ITaskManager taskManager = null!; - - // Act & Assert - ArgumentNullException exception = Assert.Throws(() => - endpoints.MapA2A(taskManager, "/a2a")); - - Assert.Equal("endpoints", exception.ParamName); - } - - /// - /// Verifies that multiple agents can be mapped to different paths. - /// - [Fact] - public void MapA2A_MultipleAgents_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agent1Builder = builder.AddAIAgent("agent1", "Instructions1", chatClientServiceKey: "chat-client"); - IHostedAgentBuilder agent2Builder = builder.AddAIAgent("agent2", "Instructions2", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - app.MapA2A(agent1Builder, "/a2a/agent1"); - app.MapA2A(agent2Builder, "/a2a/agent2"); - Assert.NotNull(app); - } - - /// - /// Verifies that custom paths can be specified for A2A endpoints. - /// - [Fact] - public void MapA2A_WithCustomPath_AcceptsValidPath() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - // Act & Assert - Should not throw - app.MapA2A(agentBuilder, "/custom/a2a/path"); - Assert.NotNull(app); - } - - /// - /// Verifies that task manager configuration callback is invoked correctly. - /// - [Fact] - public void MapA2A_WithAgentBuilder_TaskManagerConfigurationCallbackInvoked() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - bool configureCallbackInvoked = false; - - // Act - app.MapA2A(agentBuilder, "/a2a", taskManager => - { - configureCallbackInvoked = true; - Assert.NotNull(taskManager); - }); - - // Assert - Assert.True(configureCallbackInvoked); - } - - /// - /// Verifies that agent card with all properties is accepted. - /// - [Fact] - public void MapA2A_WithAgentBuilder_FullAgentCard_Succeeds() - { - // Arrange - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - IChatClient mockChatClient = new DummyChatClient(); - builder.Services.AddKeyedSingleton("chat-client", mockChatClient); - IHostedAgentBuilder agentBuilder = builder.AddAIAgent("agent", "Instructions", chatClientServiceKey: "chat-client"); - builder.Services.AddLogging(); - using WebApplication app = builder.Build(); - - var agentCard = new AgentCard - { - Name = "Test Agent", - Description = "A comprehensive test agent" - }; - - // Act & Assert - Should not throw - var result = app.MapA2A(agentBuilder, "/a2a", agentCard); - Assert.NotNull(result); - } -} From 58ff4ad3a98b2deb5e1208458ab932d41dac0892 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 23 Apr 2026 10:14:51 +0200 Subject: [PATCH 33/52] Python: Hyperlight: thread-confine sandbox, skip parsing on host callbacks, schema/tool cleanup (#5424) * improved parsing of tool call results and tweaks * Address PR review: skip_parsing flag, broader registry close, comment fix - FunctionTool.invoke now takes a boolean skip_parsing flag instead of the SKIP_PARSING sentinel; the sentinel is still accepted as result_parser at construction time to opt out of parsing for every call. The two paths are equivalent. - _SandboxRegistry.close now invokes any sandbox close/shutdown hook on the entry's own worker thread (PyO3 unsendable), then shuts the worker down, then cleans up the per-entry temporary directories. - Clarified the _SandboxWorker.shutdown comment to describe the actual ThreadPoolExecutor.shutdown(wait=False, cancel_futures=False) semantics. - Hyperlight host callback uses skip_parsing=True (the new flag). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Drop redundant 'is not SKIP_PARSING' guard that mypy 1.x flags After callable(configured_parser) the sentinel is already excluded; the extra identity check tripped mypy's non-overlapping identity warning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixed sandbox working on copy of tool --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../packages/core/agent_framework/__init__.py | 2 + .../packages/core/agent_framework/_tools.py | 100 ++++++++- python/packages/core/tests/core/test_tools.py | 162 ++++++++++++++ python/packages/hyperlight/README.md | 6 + .../_execute_code_tool.py | 210 +++++++++++------- .../hyperlight/test_hyperlight_codeact.py | 188 ++++++++++++++++ 6 files changed, 576 insertions(+), 92 deletions(-) diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 7475b1eb96..05f65873bc 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -125,6 +125,7 @@ from ._telemetry import ( prepend_agent_framework_to_user_agent, ) from ._tools import ( + SKIP_PARSING, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, @@ -258,6 +259,7 @@ __all__ = [ "GROUP_INDEX_KEY", "GROUP_KIND_KEY", "GROUP_TOKEN_COUNT_KEY", + "SKIP_PARSING", "SUMMARIZED_BY_SUMMARY_ID_KEY", "SUMMARY_OF_GROUP_IDS_KEY", "SUMMARY_OF_MESSAGE_IDS_KEY", diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index 5f5e91b656..3f15472a5a 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -94,6 +94,33 @@ ApprovalMode: TypeAlias = Literal["always_require", "never_require"] ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) + +class _SkipParsingSentinel: + """Sentinel signaling that :meth:`FunctionTool.invoke` should return the raw value. + + When passed as ``result_parser`` to :class:`FunctionTool` (or the ``@tool`` decorator), + the default :meth:`FunctionTool.parse_result` is bypassed and the wrapped function's + return value is returned unchanged from :meth:`FunctionTool.invoke`. Callers may also + request the raw value on a per-call basis by passing ``skip_parsing=True`` to + :meth:`FunctionTool.invoke`. + + Use the module-level ``SKIP_PARSING`` singleton — do not instantiate this class. + """ + + _instance: ClassVar[_SkipParsingSentinel | None] = None + + def __new__(cls) -> _SkipParsingSentinel: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "SKIP_PARSING" + + +SKIP_PARSING: Final[_SkipParsingSentinel] = _SkipParsingSentinel() +"""Sentinel for ``FunctionTool(result_parser=...)`` meaning "do not parse the result".""" + # region Helpers @@ -279,7 +306,7 @@ class FunctionTool(SerializationMixin): additional_properties: dict[str, Any] | None = None, func: Callable[..., Any] | None = None, input_model: type[BaseModel] | Mapping[str, Any] | None = None, - result_parser: Callable[[Any], str | list[Content]] | None = None, + result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None, **kwargs: Any, ) -> None: """Initialize the FunctionTool. @@ -327,9 +354,11 @@ class FunctionTool(SerializationMixin): result_parser: An optional callable with signature ``Callable[[Any], str]`` that overrides the default result parsing behavior. When provided, this callable is used to convert the raw function return value to a string instead of the - built-in :meth:`parse_result` logic. Depending on your function, it may be - easiest to just do the serialization directly in the function body rather - than providing a custom ``result_parser``. + built-in :meth:`parse_result` logic. Pass the :data:`SKIP_PARSING` sentinel + instead of a callable to opt out of parsing entirely; in that case + :meth:`invoke` returns the wrapped function's raw return value. Depending + on your function, it may be easiest to just do the serialization directly + in the function body rather than providing a custom ``result_parser``. **kwargs: Additional keyword arguments. """ # Core attributes (formerly from BaseTool) @@ -508,31 +537,65 @@ class FunctionTool(SerializationMixin): self.invocation_exception_count += 1 raise + @overload async def invoke( self, *, arguments: BaseModel | Mapping[str, Any] | None = None, context: FunctionInvocationContext | None = None, tool_call_id: str | None = None, + skip_parsing: Literal[True], **kwargs: Any, - ) -> list[Content]: + ) -> Any: ... + + @overload + async def invoke( + self, + *, + arguments: BaseModel | Mapping[str, Any] | None = None, + context: FunctionInvocationContext | None = None, + tool_call_id: str | None = None, + skip_parsing: Literal[False] = False, + **kwargs: Any, + ) -> list[Content]: ... + + async def invoke( + self, + *, + arguments: BaseModel | Mapping[str, Any] | None = None, + context: FunctionInvocationContext | None = None, + tool_call_id: str | None = None, + skip_parsing: bool = False, + **kwargs: Any, + ) -> list[Content] | Any: """Run the AI function with the provided arguments as a Pydantic model. The raw return value of the wrapped function is automatically parsed into a ``list[Content]`` using :meth:`parse_result` or the custom ``result_parser`` - if one was provided. Every result — text, rich media, or serialized objects — - is represented uniformly as Content items. + configured on the tool. Every result — text, rich media, or serialized + objects — is represented uniformly as Content items. + + Parsing can be skipped in two ways: configure the tool with + ``result_parser=SKIP_PARSING`` to always skip parsing, or pass + ``skip_parsing=True`` per call. Either way the wrapped function's raw value + is returned. This is intended for callers (e.g. sandboxed runtimes) that + consume the value from Python directly and would otherwise undo the + ``Content`` wrapping. Keyword Args: arguments: A mapping or model instance containing the arguments for the function. context: Explicit function invocation context carrying runtime kwargs. tool_call_id: Optional tool call identifier used for telemetry and tracing. + skip_parsing: When ``True``, bypass parsing and return the wrapped function's + raw value instead of a ``list[Content]``. Defaults to ``False``. kwargs: Direct function argument values. When provided, every keyword must match a declared tool parameter. Runtime data must be passed via ``context``. Returns: - A list of Content items representing the tool output. + ``list[Content]`` by default. The raw function return value (``Any``) when + ``skip_parsing=True`` (or the tool was constructed with + ``result_parser=SKIP_PARSING``). Raises: TypeError: If arguments is not mapping-like or fails schema checks. @@ -544,7 +607,9 @@ class FunctionTool(SerializationMixin): from ._types import Content from .observability import OBSERVABILITY_SETTINGS - parser = self.result_parser or FunctionTool.parse_result + configured_parser = self.result_parser + skip_parsing = skip_parsing or configured_parser is SKIP_PARSING + parser = configured_parser if callable(configured_parser) else FunctionTool.parse_result parameter_names = set(self.parameters().get("properties", {}).keys()) direct_argument_kwargs = ( @@ -616,6 +681,10 @@ class FunctionTool(SerializationMixin): logger.debug(f"Function arguments: {observable_kwargs}") res = self.__call__(**call_kwargs) result = await res if inspect.isawaitable(res) else res + if skip_parsing: + logger.info(f"Function {self.name} succeeded.") + logger.debug(f"Function result: {type(result).__name__}") + return result try: parsed = parser(result) except Exception: @@ -671,6 +740,13 @@ class FunctionTool(SerializationMixin): logger.error(f"Function failed. Error: {exception}") raise else: + if skip_parsing: + logger.info(f"Function {self.name} succeeded.") + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined] + result_str = str(result) + span.set_attribute(OtelAttr.TOOL_RESULT, result_str) + logger.debug(f"Function result: {result_str}") + return result try: parsed = parser(result) except Exception: @@ -1067,7 +1143,7 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - result_parser: Callable[[Any], str | list[Content]] | None = None, + result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None, ) -> FunctionTool: ... @@ -1083,7 +1159,7 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - result_parser: Callable[[Any], str | list[Content]] | None = None, + result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None, ) -> Callable[[Callable[..., Any]], FunctionTool]: ... @@ -1098,7 +1174,7 @@ def tool( max_invocations: int | None = None, max_invocation_exceptions: int | None = None, additional_properties: dict[str, Any] | None = None, - result_parser: Callable[[Any], str | list[Content]] | None = None, + result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None, ) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]: """Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically. diff --git a/python/packages/core/tests/core/test_tools.py b/python/packages/core/tests/core/test_tools.py index 6fa7172295..b3762bf4ef 100644 --- a/python/packages/core/tests/core/test_tools.py +++ b/python/packages/core/tests/core/test_tools.py @@ -8,6 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE from pydantic import BaseModel from agent_framework import ( + SKIP_PARSING, Content, FunctionTool, tool, @@ -1300,4 +1301,165 @@ def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None assert normalized[1] is standalone +# region SKIP_PARSING sentinel & skip_parsing + + +async def test_invoke_skip_parsing_returns_native_value() -> None: + """invoke(skip_parsing=True) returns the wrapped function's raw value.""" + + @tool + def get_weather(city: str) -> dict[str, Any]: + """Get the weather.""" + return {"city": city, "temperature_c": 21.5, "conditions": "partly cloudy"} + + raw = await get_weather.invoke(arguments={"city": "Seattle"}, skip_parsing=True) + + assert isinstance(raw, dict) + assert raw == {"city": "Seattle", "temperature_c": 21.5, "conditions": "partly cloudy"} + + +async def test_invoke_skip_parsing_passes_through_custom_objects() -> None: + """skip_parsing must not call str()/repr() on the result.""" + + class Custom: # noqa: B903 + def __init__(self, value: int) -> None: + self.value = value + + @tool + def make() -> Custom: + """Make a custom object.""" + return Custom(42) + + raw = await make.invoke(skip_parsing=True) + + assert isinstance(raw, Custom) + assert raw.value == 42 + + +async def test_invoke_skip_parsing_awaits_async_functions() -> None: + @tool + async def slow(x: int) -> int: + """Async tool.""" + return x * 2 + + raw = await slow.invoke(arguments={"x": 21}, skip_parsing=True) + assert raw == 42 + + +async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None: + """The tool's own result_parser is bypassed when skip_parsing=True is requested.""" + parser_calls: list[Any] = [] + + def parser(value: Any) -> str: + parser_calls.append(value) + return "PARSED" + + @tool(result_parser=parser) + def make_dict() -> dict[str, int]: + """Returns a dict.""" + return {"a": 1} + + raw = await make_dict.invoke(skip_parsing=True) + assert raw == {"a": 1} + assert parser_calls == [] + + # Sanity: omitting skip_parsing still applies the configured parser. + parsed = await make_dict.invoke() + assert parsed[0].type == "text" + assert parsed[0].text == "PARSED" + + +async def test_constructor_skip_parsing_sentinel_returns_raw_by_default() -> None: + """Constructing a tool with result_parser=SKIP_PARSING makes invoke return the raw value.""" + + @tool(result_parser=SKIP_PARSING) + def make_dict() -> dict[str, int]: + """Returns a dict.""" + return {"a": 1} + + raw = await make_dict.invoke() + assert raw == {"a": 1} + + +async def test_invoke_skip_parsing_validates_arguments() -> None: + """Argument validation is shared with the default path.""" + + @tool + def adder(x: int, y: int) -> int: + """Add.""" + return x + y + + with pytest.raises(TypeError): + await adder.invoke(arguments={"x": "not-an-int", "y": 1}, skip_parsing=True) + + +async def test_invoke_skip_parsing_rejects_unexpected_runtime_kwargs() -> None: + @tool + async def echo(message: str) -> str: + """Echo.""" + return message + + with pytest.raises(TypeError, match="Unexpected keyword argument"): + await echo.invoke(arguments={"message": "hi"}, skip_parsing=True, api_token="secret") + + +async def test_invoke_skip_parsing_raises_for_declaration_only_tool() -> None: + declared = FunctionTool(name="dummy", description="declaration only") + + from agent_framework.exceptions import ToolException + + with pytest.raises(ToolException): + await declared.invoke(arguments={}, skip_parsing=True) + + +async def test_invoke_skip_parsing_records_telemetry(span_exporter: InMemorySpanExporter) -> None: + """skip_parsing participates in OTEL spans and records str(raw) as TOOL_RESULT.""" + + @tool(name="raw_tool", description="raw tool") + def returns_dict(x: int) -> dict[str, int]: + """Returns a dict.""" + return {"value": x} + + span_exporter.clear() + raw = await returns_dict.invoke(arguments={"x": 5}, tool_call_id="raw_call", skip_parsing=True) + + assert raw == {"value": 5} + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.attributes[OtelAttr.TOOL_NAME] == "raw_tool" + assert span.attributes[OtelAttr.TOOL_CALL_ID] == "raw_call" + assert span.attributes[OtelAttr.TOOL_RESULT] == "{'value': 5}" + + +async def test_invoke_default_path_records_parsed_telemetry( + span_exporter: InMemorySpanExporter, +) -> None: + """Regression: omitting skip_parsing still records the parsed result in telemetry.""" + + def parser(value: Any) -> str: + return f"parsed:{value}" + + @tool(name="parsed_tool", description="parsed", result_parser=parser) + def returns_int() -> int: + """Returns an int.""" + return 7 + + span_exporter.clear() + parsed = await returns_int.invoke(tool_call_id="parsed_call") + + assert parsed[0].text == "parsed:7" + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].attributes[OtelAttr.TOOL_RESULT] == "parsed:7" + + +def test_skip_parsing_is_singleton() -> None: + """SKIP_PARSING is a singleton; instantiation returns the same object.""" + from agent_framework._tools import _SkipParsingSentinel + + assert _SkipParsingSentinel() is SKIP_PARSING + assert repr(SKIP_PARSING) == "SKIP_PARSING" + + # endregion diff --git a/python/packages/hyperlight/README.md b/python/packages/hyperlight/README.md index 1b1bc1e0ce..afc36f9365 100644 --- a/python/packages/hyperlight/README.md +++ b/python/packages/hyperlight/README.md @@ -130,3 +130,9 @@ codeact = HyperlightCodeActProvider( - `allowed_domains` accepts a single string target such as `"github.com"` to allow all backend-supported methods, an explicit `(target, method_or_methods)` tuple such as `("github.com", "GET")`, or an `AllowedDomain` named tuple. +- Tools registered with the sandbox return their native Python value + (`dict`, `list`, primitives, or custom objects) directly to the guest via the + Hyperlight FFI. Any `result_parser` configured on a `FunctionTool` is + intended for LLM-facing consumers and does not run on the sandbox path — + apply formatting inside the tool function itself if you need it for + in-sandbox consumers. diff --git a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py index a46707ac0d..f91eeb5215 100644 --- a/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py +++ b/python/packages/hyperlight/agent_framework_hyperlight/_execute_code_tool.py @@ -2,42 +2,45 @@ from __future__ import annotations -import ast import asyncio -import copy import mimetypes import shutil import threading import time from collections.abc import Callable, Sequence -from dataclasses import dataclass +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import suppress +from copy import copy +from dataclasses import dataclass, field from pathlib import Path, PurePosixPath from tempfile import TemporaryDirectory -from typing import Annotated, Any, Protocol, TypeGuard, cast +from typing import Any, Protocol, TypeGuard, TypeVar, cast from urllib.parse import urlparse from agent_framework import Content, FunctionTool from agent_framework._tools import ApprovalMode, normalize_tools -from pydantic import BaseModel, Field from ._instructions import build_codeact_instructions, build_execute_code_description from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput DEFAULT_HYPERLIGHT_BACKEND = "wasm" DEFAULT_HYPERLIGHT_MODULE = "python_guest.path" -EXECUTE_CODE_INPUT_DESCRIPTION = "Python code to execute in an isolated Hyperlight sandbox." +EXECUTE_CODE_TOOL_DESCRIPTION = "Execute Python in an isolated Hyperlight sandbox." OUTPUT_FILE_RETRY_ATTEMPTS = 10 OUTPUT_FILE_RETRY_DELAY_SECONDS = 0.1 - -class _ExecuteCodeInput(BaseModel): - code: Annotated[str, Field(description=EXECUTE_CODE_INPUT_DESCRIPTION)] - - -@dataclass(frozen=True, slots=True) -class _StoredFileMount: - host_path: Path - mount_path: str +EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "title": "_ExecuteCodeInput", + "properties": { + "code": { + "type": "string", + "title": "Code", + "description": "Python code to execute in an isolated Hyperlight sandbox.", + }, + }, + "required": ["code"], +} @dataclass(frozen=True, slots=True) @@ -85,13 +88,43 @@ class SandboxRuntime(Protocol): def execute(self, *, config: _RunConfig, code: str) -> list[Content]: ... +_T = TypeVar("_T") + + +class _SandboxWorker: + """Single-threaded executor that confines all sandbox operations to one OS thread. + + The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3, meaning it can only be + accessed from the OS thread that created it; touching it from any other thread triggers a + Rust panic that cannot be caught from Python. Every cached :class:`_SandboxEntry` therefore + owns its own ``_SandboxWorker``, and *all* lifecycle and execution calls against the + underlying sandbox object must be routed through :meth:`submit`/:meth:`run`. + """ + + __slots__ = ("_executor",) + + def __init__(self, *, name: str = "hl-sandbox") -> None: + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=name) + + def submit(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> Future[_T]: + return self._executor.submit(fn, *args, **kwargs) + + def run(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T: + return self._executor.submit(fn, *args, **kwargs).result() + + def shutdown(self) -> None: + # Do not block on shutdown; stop accepting new tasks, but allow the currently running + # task and any already-queued tasks to finish before the worker thread exits. + self._executor.shutdown(wait=False, cancel_futures=False) + + @dataclass class _SandboxEntry: sandbox: Any snapshot: Any input_dir: TemporaryDirectory[str] | None output_dir: TemporaryDirectory[str] | None - lock: threading.RLock + worker: _SandboxWorker = field(default_factory=_SandboxWorker) def _load_sandbox_class() -> type[Any]: @@ -106,10 +139,6 @@ def _load_sandbox_class() -> type[Any]: return Sandbox -def _passthrough_result_parser(result: Any) -> str: - return repr(result) - - def _collect_tools(*tool_groups: Any) -> list[FunctionTool]: tools_by_name: dict[str, FunctionTool] = {} @@ -166,7 +195,7 @@ def _is_file_mount_pair(value: Any) -> TypeGuard[FileMount | tuple[FileMountHost return isinstance(host_path, (str, Path)) and isinstance(mount_path, str) -def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount: +def _normalize_file_mount_input(file_mount: FileMountInput) -> FileMount: host_path: FileMountHostPath mount_path: str if isinstance(file_mount, str): @@ -176,7 +205,7 @@ def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount: host_path = file_mount[0] mount_path = file_mount[1] - return _StoredFileMount( + return FileMount( host_path=_resolve_existing_path(host_path), mount_path=_normalize_mount_path(mount_path), ) @@ -445,18 +474,13 @@ def _build_execution_contents( def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]: - sandbox_tool = copy.copy(tool_obj) - # Auto-assign a passthrough parser so the raw return value round-trips through - # `ast.literal_eval` in the sandbox callback below. User-supplied parsers are - # left in place so callers can customize how results are exposed to the guest. - if sandbox_tool.result_parser is None: - sandbox_tool.result_parser = _passthrough_result_parser + sandbox_tool = copy(tool_obj) def _callback(**kwargs: Any) -> Any: - async def _invoke() -> list[Content]: - return await sandbox_tool.invoke(arguments=kwargs) + async def _invoke() -> Any: + return await sandbox_tool.invoke(arguments=kwargs, skip_parsing=True) - # FunctionTool.invoke() is always async. The real Hyperlight backend invokes + # FunctionTool.invoke() is async. The real Hyperlight backend invokes # registered callbacks synchronously via FFI, so this must be a sync function. # We run the async call on a dedicated thread to avoid conflicts with any # event loop that may be running on the current thread. @@ -474,22 +498,11 @@ def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]: worker.join() if error_box: raise error_box[0] - contents: list[Content] = result_box[0] - - values: list[Any] = [] - for content in contents: - if content.type == "text" and content.text is not None: - try: - values.append(ast.literal_eval(content.text)) - except (SyntaxError, ValueError): - values.append(content.text) - continue - - values.append(content.to_dict()) - - if len(values) == 1: - return values[0] - return values + # Return the raw value. The Hyperlight FFI marshals primitives (dict, list, + # str, int, float, bool, None) natively into the guest, and falls back to + # repr()/str() for unsupported types — so the guest receives real Python + # objects without a lossy host-side serialization round-trip. + return result_box[0] return _callback @@ -509,7 +522,7 @@ def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None: pass -class _SandboxRegistry: +class _SandboxRegistry(SandboxRuntime): def __init__(self) -> None: self._entries: dict[tuple[Any, ...], _SandboxEntry] = {} self._entries_lock = threading.RLock() @@ -517,28 +530,54 @@ class _SandboxRegistry: def execute(self, *, config: _RunConfig, code: str) -> list[Content]: """Execute code in a cached sandbox matching the given config. - Entries are keyed by ``config.cache_key()``. Concurrent calls with the same - key are serialized by the entry lock so they never race, but they share the - same sandbox instance. For true parallel execution, use distinct provider - instances or configs that produce different cache keys. + Entries are keyed by ``config.cache_key()``. All operations against the underlying + sandbox object are routed through the entry's dedicated single-threaded worker, which + both serializes concurrent callers and satisfies the PyO3 ``unsendable`` invariant + that the sandbox can only be touched from the thread that created it. """ + entry = self._get_or_create_entry(config) + return entry.worker.run(self._run_on_worker, entry, code) + + @staticmethod + def _run_on_worker(entry: _SandboxEntry, code: str) -> list[Content]: + entry.sandbox.restore(entry.snapshot) + _clear_directory(entry.output_dir) + result = entry.sandbox.run(code=code) + return _build_execution_contents( + result=result, + sandbox=entry.sandbox, + output_dir=entry.output_dir, + code=code, + ) + + def _get_or_create_entry(self, config: _RunConfig) -> _SandboxEntry: cache_key = config.cache_key() with self._entries_lock: entry = self._entries.get(cache_key) if entry is None: entry = self._create_entry(config) self._entries[cache_key] = entry + return entry - with entry.lock: - entry.sandbox.restore(entry.snapshot) - _clear_directory(entry.output_dir) - result = entry.sandbox.run(code=code) - return _build_execution_contents( - result=result, - sandbox=entry.sandbox, - output_dir=entry.output_dir, - code=code, - ) + def close(self) -> None: + """Shut down all per-entry worker threads and release per-entry resources. + + Safe to call multiple times. Runs any sandbox close hook on the entry's + own worker thread to honor the PyO3 ``unsendable`` invariant. + """ + with self._entries_lock: + entries = list(self._entries.values()) + self._entries.clear() + for entry in entries: + close_hook = getattr(entry.sandbox, "close", None) or getattr(entry.sandbox, "shutdown", None) + if callable(close_hook): + with suppress(Exception): + entry.worker.run(close_hook) + entry.worker.shutdown() + for tmp_dir in (entry.input_dir, entry.output_dir): + if tmp_dir is not None: + with suppress(Exception): + tmp_dir.cleanup() def _create_entry(self, config: _RunConfig) -> _SandboxEntry: input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None @@ -578,26 +617,37 @@ class _SandboxRegistry: methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None, ) - sandbox = _create_sandbox() - _configure_sandbox(sandbox=sandbox, expand_missing_scheme=False) + worker = _SandboxWorker() + + def _build_sandbox() -> tuple[Any, Any]: + sandbox = _create_sandbox() + _configure_sandbox(sandbox=sandbox, expand_missing_scheme=False) + + try: + sandbox.run("None") + except RuntimeError as exc: + if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains): + raise + + sandbox = _create_sandbox() + _configure_sandbox(sandbox=sandbox, expand_missing_scheme=True) + sandbox.run("None") + + snapshot = sandbox.snapshot() + return sandbox, snapshot try: - sandbox.run("None") - except RuntimeError as exc: - if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains): - raise + sandbox, snapshot = worker.run(_build_sandbox) + except BaseException: + worker.shutdown() + raise - sandbox = _create_sandbox() - _configure_sandbox(sandbox=sandbox, expand_missing_scheme=True) - sandbox.run("None") - - snapshot = sandbox.snapshot() return _SandboxEntry( sandbox=sandbox, snapshot=snapshot, input_dir=input_dir_handle, output_dir=output_dir_handle, - lock=threading.RLock(), + worker=worker, ) @@ -619,10 +669,10 @@ class HyperlightExecuteCodeTool(FunctionTool): ) -> None: super().__init__( name="execute_code", - description=EXECUTE_CODE_INPUT_DESCRIPTION, + description=EXECUTE_CODE_TOOL_DESCRIPTION, approval_mode="never_require", func=self._run_code, - input_model=_ExecuteCodeInput, + input_model=EXECUTE_CODE_INPUT_SCHEMA, ) self._state_lock = threading.RLock() self._registry = _registry or _SandboxRegistry() @@ -632,7 +682,7 @@ class HyperlightExecuteCodeTool(FunctionTool): self._module: str | None = module self._module_path: str | None = module_path self._managed_tools: list[FunctionTool] = [] - self._file_mounts: dict[str, _StoredFileMount] = {} + self._file_mounts: dict[str, FileMount] = {} self._allowed_domains: dict[str, AllowedDomain] = {} if tools is not None: @@ -648,7 +698,7 @@ class HyperlightExecuteCodeTool(FunctionTool): def description(self) -> str: state_lock = getattr(self, "_state_lock", None) if state_lock is None: - return str(self.__dict__.get("description", EXECUTE_CODE_INPUT_DESCRIPTION)) + return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION)) with state_lock: allowed_domains = sorted(self._allowed_domains.values(), key=lambda value: value.target) @@ -841,9 +891,9 @@ class HyperlightExecuteCodeTool(FunctionTool): workspace_signature = _path_tree_signature(workspace_root) if workspace_root is not None else () normalized_mounts = tuple( _NormalizedFileMount( - host_path=mount.host_path, + host_path=Path(mount.host_path), mount_path=mount.mount_path, - path_signature=_path_tree_signature(mount.host_path), + path_signature=_path_tree_signature(Path(mount.host_path)), ) for mount in stored_mounts ) diff --git a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py index ab6a3f7c78..e41a5a6ee1 100644 --- a/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py +++ b/python/packages/hyperlight/tests/hyperlight/test_hyperlight_codeact.py @@ -937,3 +937,191 @@ async def test_run_code_does_not_block_event_loop() -> None: assert concurrent_ran, "Event loop was blocked during sandbox execution" assert result[0].type == "text" + + +class _ThreadAffinityFakeSandbox(_FakeSandbox): + """Fake sandbox that records the OS thread of every method invocation. + + Mirrors the PyO3 ``unsendable`` invariant of ``hyperlight_sandbox.WasmSandbox``: + if ``__init__``, ``register_tool``, ``allow_domain``, ``run``, ``snapshot`` or ``restore`` + are ever called from more than one thread for a given instance, the test fails. + """ + + affinity_failures: list[str] = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._owner_thread = threading.get_ident() + self.thread_ids: set[int] = {self._owner_thread} + + def _record(self, method: str) -> None: + ident = threading.get_ident() + self.thread_ids.add(ident) + if ident != self._owner_thread: + _ThreadAffinityFakeSandbox.affinity_failures.append( + f"{method} called from thread {ident}, expected {self._owner_thread}" + ) + + def register_tool(self, name_or_tool: Any, callback: Any | None = None) -> None: + self._record("register_tool") + super().register_tool(name_or_tool, callback) + + def allow_domain(self, target: str, methods: list[str] | None = None) -> None: + self._record("allow_domain") + super().allow_domain(target, methods) + + def run(self, code: str) -> _FakeResult: + self._record("run") + return super().run(code) + + def snapshot(self) -> str: + self._record("snapshot") + return super().snapshot() + + def restore(self, snapshot: Any) -> None: + self._record("restore") + super().restore(snapshot) + + +async def test_sandbox_calls_are_pinned_to_owning_worker_thread( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: WasmSandbox is unsendable; every sandbox call must run on its owner thread.""" + _ThreadAffinityFakeSandbox.instances.clear() + _ThreadAffinityFakeSandbox.affinity_failures.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ThreadAffinityFakeSandbox) + + execute_code = HyperlightExecuteCodeTool() + + # Invoke many times concurrently; asyncio.to_thread will spread these across the default + # executor's worker threads, which previously caused PyO3 to panic when a different thread + # touched the cached sandbox. + results = await asyncio.gather(*[execute_code.invoke(arguments={"code": "None"}) for _ in range(8)]) + for result in results: + assert result[0].type == "text" + + assert _ThreadAffinityFakeSandbox.affinity_failures == [] + assert len(_ThreadAffinityFakeSandbox.instances) == 1 + sandbox = _ThreadAffinityFakeSandbox.instances[0] + # All sandbox-touching calls must have stayed on a single owning thread, distinct from the + # caller thread that asyncio.to_thread used for dispatch. + assert sandbox.thread_ids == {sandbox._owner_thread} + assert sandbox._owner_thread != threading.get_ident() + + +async def test_sandbox_owner_thread_persists_across_dispatch_threads( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sequential calls landing on different dispatch threads still share one sandbox thread.""" + _ThreadAffinityFakeSandbox.instances.clear() + _ThreadAffinityFakeSandbox.affinity_failures.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ThreadAffinityFakeSandbox) + + execute_code = HyperlightExecuteCodeTool() + + for _ in range(5): + result = await execute_code.invoke(arguments={"code": "None"}) + assert result[0].type == "text" + + assert _ThreadAffinityFakeSandbox.affinity_failures == [] + assert len(_ThreadAffinityFakeSandbox.instances) == 1 + + +def test_sandbox_registry_close_shuts_down_workers(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox) + + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(_registry=registry) + asyncio.run(execute_code.invoke(arguments={"code": "None"})) + + entries = list(registry._entries.values()) + assert len(entries) == 1 + worker = entries[0].worker + + registry.close() + + assert registry._entries == {} + # Submitting after shutdown must fail; this proves the executor was actually torn down. + with pytest.raises(RuntimeError): + worker.submit(lambda: None) + + +def test_sandbox_registry_close_releases_per_entry_resources(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """close() must invoke any sandbox close hook and release temp directories.""" + + close_calls: list[int] = [] + + class _ClosableFakeSandbox(_FakeSandbox): + def close(self) -> None: + close_calls.append(1) + + _FakeSandbox.instances.clear() + monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ClosableFakeSandbox) + + workspace = tmp_path / "workspace" + workspace.mkdir() + registry = execute_code_module._SandboxRegistry() + execute_code = HyperlightExecuteCodeTool(workspace_root=workspace, _registry=registry) + asyncio.run(execute_code.invoke(arguments={"code": "None"})) + + entries = list(registry._entries.values()) + assert len(entries) == 1 + entry = entries[0] + assert entry.input_dir is not None and entry.output_dir is not None + input_path = Path(entry.input_dir.name) + output_path = Path(entry.output_dir.name) + assert input_path.exists() and output_path.exists() + + registry.close() + + assert close_calls == [1] + assert not input_path.exists() + assert not output_path.exists() + + +async def test_make_sandbox_callback_returns_native_dict() -> None: + """Host tool returning a dict must be forwarded as a native dict (no repr round-trip).""" + + @tool + def get_weather(city: str) -> dict[str, Any]: + """Get weather.""" + return {"city": city, "temp_c": 21.5} + + callback = execute_code_module._make_sandbox_callback(get_weather) + result = callback(city="Seattle") + + assert isinstance(result, dict) + assert result == {"city": "Seattle", "temp_c": 21.5} + + +async def test_make_sandbox_callback_bypasses_user_result_parser() -> None: + """Documented behavior change: result_parser is bypassed in the sandbox path.""" + + parser_calls: list[Any] = [] + + def parser(value: Any) -> str: + parser_calls.append(value) + return "PARSED" + + @tool(result_parser=parser) + def make_payload() -> dict[str, int]: + """Returns a dict.""" + return {"a": 1, "b": 2} + + callback = execute_code_module._make_sandbox_callback(make_payload) + result = callback() + + assert result == {"a": 1, "b": 2} + assert parser_calls == [], "result_parser must not run on the sandbox path" + + +async def test_make_sandbox_callback_propagates_exceptions() -> None: + @tool + def boom(x: int) -> int: + """Always fails.""" + raise RuntimeError("nope") + + callback = execute_code_module._make_sandbox_callback(boom) + with pytest.raises(RuntimeError, match="nope"): + callback(x=1) From 9ca55dcc0c0c75615ed27424f8a725d2b482e07b Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 17:50:29 +0900 Subject: [PATCH 34/52] Python: (chore): update changelog (#5438) * update changelog * Update python/CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update python/CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- python/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index f844b45465..64439fcbbf 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **agent-framework-core**: Add `expected_output` ground-truth support to `evaluate_workflow` for similarity evaluators ([#5234](https://github.com/microsoft/agent-framework/pull/5234)) - **agent-framework-ag-ui**, **agent-framework-a2a**: Propagate `thread_id` and `forwarded_props` through AG-UI to A2A `context_id` ([#5383](https://github.com/microsoft/agent-framework/pull/5383)) - **samples**: Add second approval-required tool (`set_stop_loss`) to `concurrent_builder_tool_approval` sample ([#4875](https://github.com/microsoft/agent-framework/pull/4875)) +- **agent-framework-core**: Add `SKIP_PARSING` sentinel for `FunctionTool.invoke` to bypass `Content`-wrapping and return raw function results ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) ### Changed - **agent-framework-foundry-hosting**: Correct Development Status classifier from Beta (4) to Alpha (3) to match the package's lifecycle stage ([#5387](https://github.com/microsoft/agent-framework/pull/5387)) - **tests**: Add Python flaky test report workflow ([#5342](https://github.com/microsoft/agent-framework/pull/5342)) +- **agent-framework-hyperlight**: Simplify host callback to pass raw Python results via `SKIP_PARSING`, switch `execute_code` input schema to a plain JSON-schema dict, and tighten public API surface ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) ### Fixed - **agent-framework-openai**: Fix OpenAI Responses streaming to propagate `created_at` from the final `response.completed` event ([#5382](https://github.com/microsoft/agent-framework/pull/5382)) @@ -24,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125)) - **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414)) - **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384)) +- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads + ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) ## [1.1.0] - 2026-04-21 From c9e603304878978280bb99ed44628044e83ff7ca Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:22:04 +0900 Subject: [PATCH 35/52] Automated issue triage workflow (#5419) * Automated issue triage workflow * Bump dependencies * Fix issue-triage workflow: security, reliability, and testability Address six review comments on the issue-triage workflow: 1. Change trigger from issues:opened to issues:labeled so the secret-backed triage flow is only triggered by a maintainer- controlled signal. 2. Include inputs.issue_number in the concurrency group so workflow_dispatch runs for the same issue are properly de-duplicated. 3. Improve team membership error handling to fail closed: verify the team exists before checking membership, and only treat a 404 as 'not a member' (all other errors fail the job). 4. Use optional chaining (issue.user?.login) for the API-fetched issue to handle deleted GitHub accounts without crashing. 5. Extract the inline github-script into a testable module at .github/scripts/check_team_membership.js with 10 tests in .github/tests/test_check_team_membership.js covering all code paths (payload/API author resolution, deleted accounts, team lookup failure, 404 vs non-404 membership errors). 6. Make the spam gate actually stop the job by exiting non-zero instead of just logging, so future steps cannot accidentally run for spam issues. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make issue-triage workflow manually triggered only for initial testing Remove the 'issues' event trigger, keeping only 'workflow_dispatch' so the workflow can be tested manually before enabling automatic triggers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/check_team_membership.js | 61 +++++++ .github/tests/test_check_team_membership.js | 178 ++++++++++++++++++++ .github/workflows/issue-triage.yml | 168 ++++++++++++++++++ 3 files changed, 407 insertions(+) create mode 100644 .github/scripts/check_team_membership.js create mode 100644 .github/tests/test_check_team_membership.js create mode 100644 .github/workflows/issue-triage.yml diff --git a/.github/scripts/check_team_membership.js b/.github/scripts/check_team_membership.js new file mode 100644 index 0000000000..ca8e75f1d1 --- /dev/null +++ b/.github/scripts/check_team_membership.js @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +/** + * Resolve the issue author and check their team membership. + * + * @param {object} opts + * @param {object} opts.github - Octokit REST client from actions/github-script + * @param {object} opts.context - GitHub Actions context + * @param {object} opts.core - GitHub Actions core toolkit + * @param {string} opts.teamSlug - Team slug to check membership against + * @param {string|number} opts.issueNumber - Issue number to resolve author for + * @returns {Promise<{author: string|null, isTeamMember: boolean}>} + */ +async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) { + let author = context.payload.issue?.user?.login; + if (!author) { + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: Number(issueNumber), + }); + author = issue.user?.login; + } + + if (!author) { + core.setFailed('Could not determine issue author (user may be deleted).'); + return { author: null, isTeamMember: false }; + } + + try { + await github.rest.teams.getByName({ + org: context.repo.owner, + team_slug: teamSlug, + }); + } catch (error) { + core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`); + throw error; + } + + let isTeamMember = false; + try { + const teamMembership = await github.rest.teams.getMembershipForUserInOrg({ + org: context.repo.owner, + team_slug: teamSlug, + username: author, + }); + isTeamMember = teamMembership.data.state === 'active'; + } catch (error) { + if (error.status === 404) { + core.info(`Author ${author} is not a member of team ${teamSlug}.`); + isTeamMember = false; + } else { + core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`); + throw error; + } + } + + return { author, isTeamMember }; +} + +module.exports = checkTeamMembership; diff --git a/.github/tests/test_check_team_membership.js b/.github/tests/test_check_team_membership.js new file mode 100644 index 0000000000..6fbec9ff60 --- /dev/null +++ b/.github/tests/test_check_team_membership.js @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft. All rights reserved. + +/** + * Tests for check_team_membership.js. + * + * Run with: node --test .github/tests/test_check_team_membership.js + */ + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const checkTeamMembership = require('../scripts/check_team_membership.js'); + + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) { + const core = { + _infoMessages: [], + _failedMessages: [], + info(msg) { this._infoMessages.push(msg); }, + setFailed(msg) { this._failedMessages.push(msg); }, + }; + + const context = { + payload: { issue: payloadIssue }, + repo: { owner: 'test-org', repo: 'test-repo' }, + }; + + const github = { + rest: { + issues: { + get: async () => ({ + data: { user: apiUser ? { login: apiUser } : null }, + }), + }, + teams: { + getByName: async () => ({}), + getMembershipForUserInOrg: async () => ({ + data: { state: teamState }, + }), + }, + }, + }; + + return { core, context, github }; +} + +const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' }; + + +// --------------------------------------------------------------------------- +// Author resolution +// --------------------------------------------------------------------------- + +describe('author resolution', () => { + it('resolves author from event payload', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'payload-user' } }, + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'payload-user'); + }); + + it('resolves author via API when payload issue is absent', async () => { + const { github, context, core } = createMocks({ apiUser: 'api-user' }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'api-user'); + }); + + it('resolves author via API when payload issue user is null (deleted account)', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: null }, + apiUser: 'fetched-user', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, 'fetched-user'); + }); + + it('handles deleted account when API also returns null user', async () => { + const { github, context, core } = createMocks({ apiUser: null }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.author, null); + assert.equal(result.isTeamMember, false); + assert.ok(core._failedMessages.some(m => m.includes('deleted'))); + }); +}); + + +// --------------------------------------------------------------------------- +// Team lookup +// --------------------------------------------------------------------------- + +describe('team lookup', () => { + it('fails the job when team lookup errors', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const error = new Error('Bad credentials'); + github.rest.teams.getByName = async () => { throw error; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === error, + ); + assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed'))); + }); +}); + + +// --------------------------------------------------------------------------- +// Team membership +// --------------------------------------------------------------------------- + +describe('team membership', () => { + it('returns true for active team member', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'member' } }, + teamState: 'active', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, true); + }); + + it('returns false for pending team member', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'pending-user' } }, + teamState: 'pending', + }); + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, false); + }); + + it('treats 404 membership response as non-member without failing', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'outsider' } }, + }); + const notFoundError = new Error('Not Found'); + notFoundError.status = 404; + github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; }; + + const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS }); + assert.equal(result.isTeamMember, false); + assert.equal(core._failedMessages.length, 0); + assert.ok(core._infoMessages.some(m => m.includes('not a member'))); + }); + + it('fails the job on non-404 membership errors', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const serverError = new Error('Internal Server Error'); + serverError.status = 500; + github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === serverError, + ); + assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed'))); + }); + + it('fails the job on membership errors without status code', async () => { + const { github, context, core } = createMocks({ + payloadIssue: { user: { login: 'user1' } }, + }); + const networkError = new Error('ECONNREFUSED'); + github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; }; + + await assert.rejects( + () => checkTeamMembership({ github, context, core, ...BASE_OPTS }), + (err) => err === networkError, + ); + assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed'))); + }); +}); diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml new file mode 100644 index 0000000000..813ef8a219 --- /dev/null +++ b/.github/workflows/issue-triage.yml @@ -0,0 +1,168 @@ +name: Issue Triage + +on: + workflow_dispatch: + inputs: + issue_number: + description: Issue number to triage + required: true + type: string + +permissions: + contents: read + issues: write + +concurrency: + group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }} + cancel-in-progress: true + +env: + DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }} + DEVFLOW_REF: main + TARGET_REPO_PATH: ${{ github.workspace }}/target-repo + DEVFLOW_PATH: ${{ github.workspace }}/devflow + +jobs: + team_check: + runs-on: ubuntu-latest + outputs: + is_team_member: ${{ steps.check.outputs.is_team_member }} + issue_number: ${{ steps.issue.outputs.issue_number }} + repo: ${{ steps.issue.outputs.repo }} + steps: + - name: Resolve issue metadata + id: issue + shell: bash + env: + ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }} + ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }} + run: | + set -euo pipefail + + if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then + issue_number="${ISSUE_NUMBER_EVENT}" + else + issue_number="${ISSUE_NUMBER_INPUT}" + fi + + if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then + echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2 + exit 1 + fi + + echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT" + echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT" + + - name: Checkout scripts + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts + fetch-depth: 1 + persist-credentials: false + + - name: Check issue author team membership + id: check + uses: actions/github-script@v8 + env: + TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }} + ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }} + with: + github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }} + script: | + const checkTeamMembership = require('./.github/scripts/check_team_membership.js'); + const { author, isTeamMember } = await checkTeamMembership({ + github, + context, + core, + teamSlug: process.env.TEAM_NAME, + issueNumber: process.env.ISSUE_NUMBER, + }); + core.setOutput('is_team_member', isTeamMember ? 'true' : 'false'); + if (isTeamMember) { + core.info(`Author ${author} is a team member; skipping auto-triage.`); + } else { + core.info(`Author ${author} is not a team member; proceeding with triage.`); + } + + triage: + runs-on: ubuntu-latest + needs: team_check + if: ${{ needs.team_check.outputs.is_team_member == 'false' }} + timeout-minutes: 60 + + steps: + # Safe checkout: base repo only. + - name: Checkout target repo base + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + path: target-repo + + # Private DevFlow (maf-dashboard) checkout. + - name: Checkout DevFlow + uses: actions/checkout@v6 + with: + repository: ${{ env.DEVFLOW_REPOSITORY }} + ref: ${{ env.DEVFLOW_REF }} + token: ${{ secrets.DEVFLOW_TOKEN }} + fetch-depth: 1 + persist-credentials: false + path: devflow + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + version: "0.11.x" + enable-cache: true + + - name: Install DevFlow dependencies + working-directory: ${{ env.DEVFLOW_PATH }} + run: uv sync --frozen + + - name: Classify issue relevance + id: spam + working-directory: ${{ env.DEVFLOW_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + ISSUE_REPO: ${{ needs.team_check.outputs.repo }} + ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + run: | + uv run python scripts/classify_issue_spam.py \ + --repo "$ISSUE_REPO" \ + --issue-number "$ISSUE_NUMBER" \ + --repo-path "${TARGET_REPO_PATH}" \ + --apply-labels + + - name: Stop after spam gate + if: ${{ steps.spam.outputs.decision != 'allow' }} + shell: bash + env: + SPAM_DECISION: ${{ steps.spam.outputs.decision }} + run: | + echo "Stopping: spam gate decided: ${SPAM_DECISION}" + exit 1 + + - name: Reproduce reported issue + if: ${{ steps.spam.outputs.decision == 'allow' }} + id: repro + working-directory: ${{ env.DEVFLOW_PATH }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }} + SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} + ISSUE_REPO: ${{ needs.team_check.outputs.repo }} + ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + run: | + uv run python scripts/trigger_issue_repro.py \ + --repo "$ISSUE_REPO" \ + --issue-number "$ISSUE_NUMBER" \ + --github-username "$GITHUB_ACTOR" From fbbc2ebe86cd144338a6cf9f3e1d0d013bf51b38 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:01:24 +0900 Subject: [PATCH 36/52] Propagate integration-test model credentials to issue-triage repro (#5443) Scopes the triage job to the integration GitHub Environment, adds the azure/login OIDC step, and exposes the same OpenAI / Azure OpenAI / Foundry / Anthropic env vars the integration test workflow uses. This lets the triage agent write repro code that constructs model clients from the environment without any secrets entering the agent prompt or generated-code literals. Azure OpenAI and Foundry continue to authenticate via AAD (DefaultAzureCredential), so there is no API key to leak for those providers. --- .github/workflows/issue-triage.yml | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 813ef8a219..04267ac53b 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -11,6 +11,7 @@ on: permissions: contents: read issues: write + id-token: write concurrency: group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }} @@ -88,6 +89,7 @@ jobs: runs-on: ubuntu-latest needs: team_check if: ${{ needs.team_check.outputs.is_team_member == 'false' }} + environment: integration timeout-minutes: 60 steps: @@ -125,6 +127,13 @@ jobs: working-directory: ${{ env.DEVFLOW_PATH }} run: uv sync --frozen + - name: Azure CLI Login + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Classify issue relevance id: spam working-directory: ${{ env.DEVFLOW_PATH }} @@ -161,6 +170,28 @@ jobs: AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }} ISSUE_REPO: ${{ needs.team_check.outputs.repo }} ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }} + # Model-provider settings for generated repro code. Never enter the + # agent prompt; consumed by SDK constructors via os.environ. Azure + # OpenAI and Foundry auth via AAD from the azure/login step above. + OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }} + OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }} + OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }} + OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }} + AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }} + AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }} + AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }} + AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }} + FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }} + FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }} + FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }} + FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }} + FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }} + FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }} + FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }} run: | uv run python scripts/trigger_issue_repro.py \ --repo "$ISSUE_REPO" \ From dfca81ff215774d53d676e2cef505babf2660655 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:10:49 +0100 Subject: [PATCH 37/52] .NET: Update Aspire package to be preview (#5444) * Update Aspire package to be preview * Also update readme file * Include README.md in pack --- dotnet/agent-framework-dotnet.slnx | 4 +--- .../Aspire.Hosting.AgentFramework.DevUI.csproj | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f1ade4eedc..9a5b769872 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -4,9 +4,6 @@ - - - @@ -533,6 +530,7 @@ + diff --git a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj index 0f45c95147..36b2f44b98 100644 --- a/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj +++ b/dotnet/src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj @@ -2,14 +2,22 @@ $(TargetFrameworksCore) - true - aspire integration hosting agent-framework devui ai agents - Microsoft Agent Framework DevUI support for Aspire. + preview $(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050 + + + + + Microsoft Agent Framework DevUI for Aspire + aspire integration hosting agent-framework devui ai agents + Microsoft Agent Framework DevUI support for Aspire. + README.md + + @@ -22,4 +30,7 @@ + + + From 6851a9cdc8c598e9340f81fa49d8d05e4f606977 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:10:57 +0100 Subject: [PATCH 38/52] .NET: Add dynamic tool expansion sample (#5425) * Add dynamic tool expansion sample * Address PR comments * Remove tool names from tool call response to avoid confusing LLM --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_Step20_DynamicFunctionTools.csproj | 20 ++ .../Program.cs | 281 ++++++++++++++++++ .../README.md | 38 +++ dotnet/samples/02-agents/Agents/README.md | 1 + 5 files changed, 341 insertions(+) create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs create mode 100644 dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 9a5b769872..b2b48ce03e 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -64,6 +64,7 @@ + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj new file mode 100644 index 0000000000..41aafe3437 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs new file mode 100644 index 0000000000..ac3dd4b491 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Program.cs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample demonstrates how to dynamically expand the set of function tools available to an +// agent during a function-calling loop. The agent starts with a single "RequestTools" function. +// When the model calls RequestTools with a description of the capabilities needed, the function +// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent +// can then use the newly added tools in subsequent iterations of the same function-calling loop. + +using System.ComponentModel; +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + +// Pre-defined tool implementations that can be loaded on demand. +[Description("Get the current weather for a city.")] +static string GetWeather([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 55°F, cloudy with light rain.", + "NEW YORK" => "New York: 72°F, sunny and warm.", + "LONDON" => "London: 48°F, overcast with fog.", + _ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'." + }; + +[Description("Get the current local time for a city.")] +static string GetTime([Description("The city name.")] string city) => + city.ToUpperInvariant() switch + { + "SEATTLE" => "Seattle: 9:00 AM PST", + "NEW YORK" => "New York: 12:00 PM EST", + "LONDON" => "London: 5:00 PM GMT", + _ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'." + }; + +[Description("Convert a temperature from Fahrenheit to Celsius.")] +static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) => + $"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C"; + +// A registry of tool sets that can be loaded by description keyword. +Dictionary> toolCatalog = new(StringComparer.OrdinalIgnoreCase) +{ + ["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")], + ["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")], + ["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")], +}; + +// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically. +AIFunction requestToolsFunction = AIFunctionFactory.Create( + [Description("Request additional tools to be loaded based on a description of the functionality needed. " + + "Call this when you need capabilities that are not yet available in your current tool set.")] ( + [Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description + ) => + { + // Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient. + var context = FunctionInvokingChatClient.CurrentContext + ?? throw new InvalidOperationException("No ambient FunctionInvocationContext available."); + + var tools = context.Options?.Tools; + if (tools is null) + { + return "Unable to register new tools: ChatOptions.Tools is not available."; + } + + // Find matching tool sets from the catalog. + List addedToolNames = []; + foreach (var kvp in toolCatalog) + { + var keyword = kvp.Key; + var catalogTools = kvp.Value; + if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase)) + { + foreach (var tool in catalogTools) + { + // Avoid adding duplicates. + if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name)) + { + tools.Add(tool); + addedToolNames.Add(fn.Name); + } + } + } + } + + return addedToolNames.Count > 0 + ? "Successfully loaded tools" + : $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}."; + }, + name: "RequestTools"); + +// Create the agent with only the RequestTools function initially. +// Insert chat client middleware that logs the tools available on each LLM call, +// making the dynamic expansion visible in the console output. +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsIChatClient() + .AsBuilder() + .Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware) + .BuildAIAgent( + instructions: """ + You are a helpful assistant. You start with limited tools. + When you need functionality that you don't currently have, call RequestTools with a description + of what you need. After new tools are loaded, use them to answer the user's question. + """, + tools: [requestToolsFunction]); + +// Run a conversation that triggers dynamic tool expansion. +Console.WriteLine("=== Dynamic Function Tools Sample ===\n"); + +string[] prompts = +[ + "What's the weather like in Seattle and London?", + "What time is it in New York?", + "Can you convert those temperatures to Celsius?" +]; + +// --- Non-Streaming Mode --- +Console.ForegroundColor = ConsoleColor.Yellow; +Console.WriteLine("=== Non-Streaming Mode ==="); +Console.ResetColor(); +Console.WriteLine(); + +AgentSession session = await agent.CreateSessionAsync(); + +foreach (var prompt in prompts) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[User] "); + Console.ResetColor(); + Console.WriteLine(prompt); + + var response = await agent.RunAsync(prompt, session); + + // Print all message contents including tool calls, tool results, and text. + foreach (var message in response.Messages) + { + foreach (var content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})"); + Console.ResetColor(); + break; + + case FunctionResultContent functionResult: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}"); + Console.ResetColor(); + break; + + case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text): + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[Agent] "); + Console.ResetColor(); + Console.WriteLine(textContent.Text); + break; + } + } + } + + Console.WriteLine(); +} + +// --- Streaming Mode --- +Console.ForegroundColor = ConsoleColor.Yellow; +Console.WriteLine("=== Streaming Mode ==="); +Console.ResetColor(); +Console.WriteLine(); + +AgentSession streamingSession = await agent.CreateSessionAsync(); + +foreach (var prompt in prompts) +{ + Console.ForegroundColor = ConsoleColor.Green; + Console.Write("[User] "); + Console.ResetColor(); + Console.WriteLine(prompt); + + bool inAgentText = false; + + await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession)) + { + foreach (var content in update.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + if (inAgentText) + { + Console.WriteLine(); + inAgentText = false; + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})"); + Console.ResetColor(); + break; + + case FunctionResultContent functionResult: + Console.ForegroundColor = ConsoleColor.DarkYellow; + Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}"); + Console.ResetColor(); + break; + + case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text): + if (!inAgentText) + { + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write("[Agent] "); + Console.ResetColor(); + inAgentText = true; + } + + Console.Write(textContent.Text); + break; + } + } + } + + if (inAgentText) + { + Console.WriteLine(); + } + + Console.WriteLine(); +} + +// Chat client middleware that logs the number and names of tools on each LLM request. +async Task ToolLoggingMiddleware( + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + CancellationToken cancellationToken) +{ + LogTools(options); + + return await innerChatClient.GetResponseAsync(messages, options, cancellationToken); +} + +// Streaming version of the tool logging middleware. +async IAsyncEnumerable ToolLoggingStreamingMiddleware( + IEnumerable messages, + ChatOptions? options, + IChatClient innerChatClient, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) +{ + LogTools(options); + + await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + yield return update; + } +} + +// Shared helper to log the current tool set. +void LogTools(ChatOptions? options) +{ + if (options?.Tools is { Count: > 0 } tools) + { + var toolNames = tools.OfType().Select(t => t.Name); + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}"); + Console.ResetColor(); + } + else + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" [Middleware] LLM call with 0 tools"); + Console.ResetColor(); + } +} diff --git a/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md new file mode 100644 index 0000000000..fb0245b542 --- /dev/null +++ b/dotnet/samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/README.md @@ -0,0 +1,38 @@ +# Dynamic Function Tools + +This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop. + +## What it demonstrates + +- The agent starts with only a single `RequestTools` function +- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed +- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime +- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop + +## How it works + +1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances +2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools` +3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop + +## Prerequisites + +- .NET 10 SDK or later +- Azure OpenAI service endpoint and deployment configured +- Azure CLI installed and authenticated (for Azure credential authentication) +- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource + +## Running the sample + +Set the required environment variables: + +```powershell +$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" +$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini +``` + +Run the sample: + +```powershell +dotnet run +``` diff --git a/dotnet/samples/02-agents/Agents/README.md b/dotnet/samples/02-agents/Agents/README.md index f429d8156c..af946b5fac 100644 --- a/dotnet/samples/02-agents/Agents/README.md +++ b/dotnet/samples/02-agents/Agents/README.md @@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites: |[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.| |[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.| |[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.| +|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.| ## Running the samples from the console From 69adf6d97e9d5772771ccae4691a3787cd3a1393 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Thu, 23 Apr 2026 08:27:28 -0700 Subject: [PATCH 39/52] .NET: Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts (#5412) * Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Simplify test comment. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Execution/StreamingRunEventStream.cs | 19 +++++++++------ .../SampleSmokeTest.cs | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs index ffcc61dad4..f94bd9848d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Execution/StreamingRunEventStream.cs @@ -77,12 +77,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream try { - // Wait for the first input before starting - // The consumer will call EnqueueMessageAsync which signals the run loop + // Wait for the first input before starting. + // The consumer will call EnqueueMessageAsync which signals the run loop. + // Note: AsyncRunHandle also signals here on checkpoint resume when there are + // already pending requests, so the first iteration can emit a PendingRequests + // halt signal even without unprocessed messages. await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false); - this._runStatus = RunStatus.Running; - while (!linkedSource.Token.IsCancellationRequested) { // Start a new run-stage activity for this input→processing→halt cycle @@ -95,6 +96,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Events are streamed out in real-time as they happen via the event handler if (this._stepRunner.HasUnprocessedMessages) { + // Flip to Running only when there's actual work to process. + // This is intentionally inside the HasUnprocessedMessages branch so + // that stale input signals cannot transiently flip status back to + // Running after a prior halt has already been observed by callers + // (e.g. Run.ResumeAsync returning after reading an Idle halt signal). + this._runStatus = RunStatus.Running; + // Emit WorkflowStartedEvent only when there's actual work to process // This avoids spurious events on timeout-only loop iterations await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false); @@ -129,9 +137,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream // Wait for next input from the consumer // Works for both Idle (no work) and PendingRequests (waiting for responses) await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false); - - // When signaled, resume running - this._runStatus = RunStatus.Running; } } catch (OperationCanceledException) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs index a290948ae4..c0be892e24 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/SampleSmokeTest.cs @@ -269,6 +269,29 @@ public class SampleSmokeTest _ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment()); } + /// + /// Stress regression for the off-thread run-status race: after + /// Run.ResumeAsync returns at a halt boundary, + /// callers must observe a stable terminal status and never a transient + /// . Step9 is the canonical multi-response resume + /// sample; prior to the fix in , + /// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently + /// on roughly 1-in-10 iterations under InProcess_OffThread. + /// + [Fact] + internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync() + { + const int Iterations = 50; + + for (int i = 0; i < Iterations; i++) + { + using StringWriter writer = new(); + _ = await Step9EntryPoint.RunAsync( + writer, + ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment()); + } + } + [Theory] [InlineData(ExecutionEnvironment.InProcess_Lockstep)] [InlineData(ExecutionEnvironment.InProcess_OffThread)] From 4d3e4f865f4a4a82d12412923ea1b3d3ba6f52d6 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:26:55 +0100 Subject: [PATCH 40/52] Update versions for release (#5449) --- dotnet/nuget/nuget-package.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotnet/nuget/nuget-package.props b/dotnet/nuget/nuget-package.props index 47e89a5868..3de6a561b9 100644 --- a/dotnet/nuget/nuget-package.props +++ b/dotnet/nuget/nuget-package.props @@ -1,14 +1,14 @@ - 1.2.0 + 1.3.0 1 - 260421 + 260423 $(VersionPrefix)-rc$(RCNumber) $(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1 $(VersionPrefix)-preview.$(DateSuffix).1 $(VersionPrefix) - 1.2.0 + 1.3.0 Debug;Release;Publish true From 0dbcc9fe9d031c6c8bc174543f0af9e2f933047d Mon Sep 17 00:00:00 2001 From: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:50:57 +0100 Subject: [PATCH 41/52] .NET: Add streaming support to A2A agent handler (#5427) * update a2a agent to the latest a2a sdk (#5257) * Move A2A samples from 04-hosting to 02-agents (#5267) Move the A2A sample projects (A2AAgent_AsFunctionTools and A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to samples/02-agents/A2A/ to better align with the sample directory structure. Update solution file and samples README accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Fix stream reconnection for A2AAgent (#5275) * Add SSE stream reconnection support to A2AAgent Implement automatic reconnection for SSE streams that disconnect mid-task, using the Last-Event-ID header to resume from where the stream left off. Changes: - Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable max retries and delay between attempts - Add new log messages for reconnection events - Add A2AAgent_StreamReconnection sample demonstrating the feature - Update existing polling sample to use simplified SendMessageAsync API - Add unit tests for stream reconnection logic Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments * Address PR review feedback - Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection - Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding - Print update.Text instead of update object in stream reconnection sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Use IA2AClientFactory to create A2AClient (#5277) * Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample - Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions - Update A2ACardResolverExtensions to accept IA2AClientFactory - Update A2AClientExtensions to accept IA2AClientFactory - Update A2AAgent to use IA2AClientFactory for client creation - Add A2AAgent_ProtocolSelection sample demonstrating protocol selection - Add comprehensive unit tests for all changes - Update README files with new sample reference Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Reorder params: options before loggerFactory in A2A extensions Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent and GetAIAgentAsync extension methods to follow the repo convention of keeping LoggerFactory and CancellationToken as the last parameters. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Migrate A2A hosting to A2A SDK v1 (#5363) * .NET: Migrate A2A hosting to A2A SDK v1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove unused agent card --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: Split A2A endpoint mapping into protocol-specific methods (#5413) * .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions - Rename A2AHostingOptions to A2AServerRegistrationOptions - Move server registration logic from A2AEndpointRouteBuilderExtensions and AIAgentExtensions into new A2AServerServiceCollectionExtensions - Remove A2AProtocolBinding and AIAgentExtensions (consolidated) - Update samples and tests to use the new registration API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove unnecessary using directive in AgentWebChat.AgentHost Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * restore AsyncEnumerable package version * address copilot initial feedback * address automated code review and formatting issues * fix formatting issues * Add streaming support to A2A agent handler Add HandleNewMessageStreamingAsync to A2AAgentHandler that routes StreamingResponse requests through RunStreamingAsync, enqueuing an A2A Message for each AgentResponseUpdate. Add MessageConverter.ToParts(AgentResponseUpdate) extension to convert streaming update contents to A2A Parts with unsupported-content filtering. Add CreateMessageFromUpdate to map AgentResponseUpdate to A2A Message. Add 16 new tests covering the streaming path and converter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add streaming edge-case tests for A2AAgentHandler Add two tests covering gaps in the streaming path: - ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync: Verifies that when RunStreamingAsync yields an empty async enumerable, no messages are enqueued and only SaveSessionAsync runs. - ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsyncAsync: Verifies that the CancellationToken from ExecuteAsync is propagated through to the inner agent's RunCoreStreamingAsync call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address copilot comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../A2AAgentHandler.cs | 77 +- .../Converters/MessageConverter.cs | 20 + .../A2AAgentHandlerTests.cs | 815 ++++++++++++++++++ .../Converters/MessageConverterTests.cs | 63 ++ 4 files changed, 966 insertions(+), 9 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs index 65bc4c30bd..2113d273e0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/A2AAgentHandler.cs @@ -42,11 +42,19 @@ internal sealed class A2AAgentHandler : IAgentHandler /// public Task ExecuteAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { + // Handle task updates if (context.IsContinuation) { return this.HandleTaskUpdateAsync(context, eventQueue, cancellationToken); } + // Handle messages received via streaming endpoint + if (context.StreamingResponse) + { + return this.HandleNewMessageStreamingAsync(context, eventQueue, cancellationToken); + } + + // Handle new messages received via non-streaming endpoint return this.HandleNewMessageAsync(context, eventQueue, cancellationToken); } @@ -80,13 +88,19 @@ internal sealed class A2AAgentHandler : IAgentHandler ? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses } : new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() }; - var response = await this._hostAgent.RunAsync( - chatMessages, - session: session, - options: options, - cancellationToken: cancellationToken).ConfigureAwait(false); - - await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + AgentResponse response; + try + { + response = await this._hostAgent.RunAsync( + chatMessages, + session: session, + options: options, + cancellationToken: cancellationToken).ConfigureAwait(false); + } + finally + { + await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false); + } if (response.ContinuationToken is null) { @@ -108,6 +122,39 @@ internal sealed class A2AAgentHandler : IAgentHandler } } + private async Task HandleNewMessageStreamingAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) + { + var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); + var session = await this._hostAgent.GetOrCreateSessionAsync(contextId, cancellationToken).ConfigureAwait(false); + + // AIAgent does not support resuming from arbitrary prior tasks. + // Throw explicitly so the client gets a clear error rather than a response + // that silently ignores the referenced task context. + if (context.Message?.ReferenceTaskIds is { Count: > 0 }) + { + throw new NotSupportedException("ReferenceTaskIds is not supported. AIAgent cannot resume from arbitrary prior task context."); + } + + List chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : []; + + var options = context.Metadata is { Count: > 0 } + ? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() } + : null; + + try + { + await foreach (var update in this._hostAgent.RunStreamingAsync(chatMessages, session, options, cancellationToken).ConfigureAwait(false)) + { + var message = CreateMessageFromUpdate(contextId, update); + await eventQueue.EnqueueMessageAsync(message, cancellationToken).ConfigureAwait(false); + } + } + finally + { + await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false); + } + } + private async Task HandleTaskUpdateAsync(RequestContext context, AgentEventQueue eventQueue, CancellationToken cancellationToken) { var contextId = context.ContextId ?? Guid.NewGuid().ToString("N"); @@ -141,8 +188,10 @@ internal sealed class A2AAgentHandler : IAgentHandler await failUpdater.FailAsync(message: null, CancellationToken.None).ConfigureAwait(false); throw; } - - await this._hostAgent.SaveSessionAsync(contextId, session, cancellationToken).ConfigureAwait(false); + finally + { + await this._hostAgent.SaveSessionAsync(contextId, session, CancellationToken.None).ConfigureAwait(false); + } if (response.ContinuationToken is null) { @@ -174,6 +223,16 @@ internal sealed class A2AAgentHandler : IAgentHandler Metadata = response.AdditionalProperties?.ToA2AMetadata() }; + private static Message CreateMessageFromUpdate(string contextId, AgentResponseUpdate update) => + new() + { + MessageId = update.ResponseId ?? Guid.NewGuid().ToString("N"), + ContextId = contextId, + Role = Role.Agent, + Parts = update.ToParts(), + Metadata = update.AdditionalProperties?.ToA2AMetadata() + }; + private static List ExtractChatMessagesFromTaskHistory(AgentTask? agentTask) { if (agentTask?.History is not { Count: > 0 }) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs index b2f57fc09e..a231f322c4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.A2A/Converters/MessageConverter.cs @@ -8,6 +8,26 @@ namespace Microsoft.Agents.AI.Hosting.A2A.Converters; internal static class MessageConverter { + public static List ToParts(this AgentResponseUpdate update) + { + if (update is null || update.Contents is not { Count: > 0 }) + { + return []; + } + + var parts = new List(); + foreach (var content in update.Contents) + { + var part = content.ToPart(); + if (part is not null) + { + parts.Add(part); + } + } + + return parts; + } + public static List ToParts(this IList chatMessages) { if (chatMessages is null || chatMessages.Count == 0) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs index 6558144184..b54b3d7db7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/A2AAgentHandlerTests.cs @@ -586,6 +586,457 @@ public sealed class A2AAgentHandlerTests #pragma warning restore MEAI001 + /// + /// Verifies that in streaming mode, each update from RunStreamingAsync produces a message event. + /// + [Fact] + public async Task ExecuteAsync_Streaming_EnqueuesMessageForEachUpdateAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk 1") { ResponseId = "r1" }, + new AgentResponseUpdate(ChatRole.Assistant, "chunk 2") { ResponseId = "r2" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.Equal(2, events.Messages.Count); + Assert.Equal("chunk 1", events.Messages[0].Parts![0].Text); + Assert.Equal("chunk 2", events.Messages[1].Parts![0].Text); + } + + /// + /// Verifies that in streaming mode, when metadata is present, options with AdditionalProperties + /// are passed to RunStreamingAsync. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WithMetadata_PassesOptionsWithAdditionalPropertiesAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture( + options => capturedOptions = options)); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] }, + Metadata = new Dictionary + { + ["key1"] = JsonSerializer.SerializeToElement("value1") + } + }); + + // Assert + Assert.NotNull(capturedOptions); + Assert.NotNull(capturedOptions.AdditionalProperties); + Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString()); + } + + /// + /// Verifies that in streaming mode, when metadata is null, null options are passed to RunStreamingAsync. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WithNullMetadata_PassesNullOptionsAsync() + { + // Arrange + AgentRunOptions? capturedOptions = null; + bool optionsCaptured = false; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture( + options => { capturedOptions = options; optionsCaptured = true; })); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.True(optionsCaptured); + Assert.Null(capturedOptions); + } + + /// + /// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WithReferenceTaskIds_ThrowsNotSupportedExceptionAsync() + { + // Arrange + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([])); + + // Act & Assert + var eventQueue = new AgentEventQueue(); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message + { + MessageId = "test-id", + Role = Role.User, + Parts = [new Part { Text = "Hello" }], + ReferenceTaskIds = ["other-task-id"] + } + }, + eventQueue, + CancellationToken.None)); + } + + /// + /// Verifies that in streaming mode, when ContextId is null, a new one is generated. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WhenContextIdIsNull_GeneratesContextIdAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = null!, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.ContextId); + Assert.NotEmpty(message.ContextId); + } + + /// + /// Verifies that in streaming mode, the provided ContextId is used in the response. + /// + [Fact] + public async Task ExecuteAsync_Streaming_UsesProvidedContextIdAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "my-streaming-ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("my-streaming-ctx", message.ContextId); + } + + /// + /// Verifies that in streaming mode, when Message is null, the handler succeeds with empty messages. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WhenMessageIsNull_SucceedsWithEmptyMessagesAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "Reply") { ResponseId = "r1" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = null! + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("ctx", message.ContextId); + } + + /// + /// Verifies that in streaming mode, the ResponseId from the update is used as the MessageId in the response. + /// + [Fact] + public async Task ExecuteAsync_Streaming_ResponseIdIsUsedAsMessageIdAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "resp-42" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Equal("resp-42", message.MessageId); + } + + /// + /// Verifies that in streaming mode, when ResponseId is null, a MessageId is still generated. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WhenResponseIdIsNull_GeneratesMessageIdAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = null } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.MessageId); + Assert.NotEmpty(message.MessageId); + } + + /// + /// Verifies that in streaming mode, when the update has AdditionalProperties, the message has metadata. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WithResponseAdditionalProperties_ReturnsMessageWithMetadataAsync() + { + // Arrange + AdditionalPropertiesDictionary additionalProps = new() + { + ["streamKey"] = "streamValue" + }; + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = additionalProps } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.NotNull(message.Metadata); + Assert.True(message.Metadata.ContainsKey("streamKey")); + } + + /// + /// Verifies that in streaming mode, when the update has null AdditionalProperties, the message has null metadata. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WithNullAdditionalProperties_ReturnsMessageWithNullMetadataAsync() + { + // Arrange + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1", AdditionalProperties = null } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates)); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Message message = Assert.Single(events.Messages); + Assert.Null(message.Metadata); + } + + /// + /// Verifies that in streaming mode, the session is saved after all updates are processed. + /// + [Fact] + public async Task ExecuteAsync_Streaming_SavesSessionAfterProcessingAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponseUpdate[] updates = + [ + new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" } + ]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object); + + // Act + await InvokeExecuteAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx-stream", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert - verify session was saved + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-stream"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that in streaming mode, when RunStreamingAsync yields no updates, + /// no messages are enqueued and the session is still saved. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WhenNoUpdates_EnqueuesNoMessagesAndSavesSessionAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(ValueTask.CompletedTask); + + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock([]), agentSessionStore: mockSessionStore.Object); + + // Act + var events = await CollectEventsAsync(handler, new RequestContext + { + StreamingResponse = true, + TaskId = "", + ContextId = "ctx", + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }); + + // Assert + Assert.Empty(events.Messages); + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx"), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that the CancellationToken is propagated to RunStreamingAsync in the streaming path. + /// + [Fact] + public async Task ExecuteAsync_Streaming_CancellationTokenIsPropagatedToRunStreamingAsync() + { + // Arrange + CancellationToken capturedToken = default; + using var cts = new CancellationTokenSource(); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, _, _, ct) => capturedToken = ct) + .Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }])); + + A2AAgentHandler handler = CreateHandler(agentMock); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", + ContextId = "ctx", + StreamingResponse = true, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert + Assert.Equal(cts.Token, capturedToken); + } + /// /// Verifies that when no session store is provided, the handler uses InMemoryAgentSessionStore /// and can execute successfully. @@ -821,6 +1272,308 @@ public sealed class A2AAgentHandlerTests Assert.True(capturedOptions.AllowBackgroundResponses); } + /// + /// Verifies that in the non-streaming path, SaveSessionAsync is called with + /// CancellationToken.None even when RunAsync throws an exception. + /// + [Fact] + public async Task ExecuteAsync_NonStreaming_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new InvalidOperationException("Agent failed")); + + using var cts = new CancellationTokenSource(); + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object); + + // Act + var eventQueue = new AgentEventQueue(); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token)); + + // Assert - SaveSessionAsync was called with CancellationToken.None despite the exception + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + + /// + /// Verifies that in the streaming path, SaveSessionAsync is called with + /// CancellationToken.None even when RunStreamingAsync throws an exception. + /// + [Fact] + public async Task ExecuteAsync_Streaming_WhenRunStreamingAsyncThrows_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock.Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(() => ToThrowingAsyncEnumerableAsync(new InvalidOperationException("Stream failed"))); + + using var cts = new CancellationTokenSource(); + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object); + + // Act + var eventQueue = new AgentEventQueue(); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx-stream", StreamingResponse = true, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token)); + + // Assert - SaveSessionAsync was called with CancellationToken.None despite the exception + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-stream"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + + /// + /// Verifies that on the continuation path, SaveSessionAsync is called with + /// CancellationToken.None even when RunAsync throws an exception. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_WhenRunAsyncThrows_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock.Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock.Protected() + .Setup>("RunCoreAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(new InvalidOperationException("Agent failed")); + + using var cts = new CancellationTokenSource(); + A2AAgentHandler handler = CreateHandler(agentMock, agentSessionStore: mockSessionStore.Object); + + // Act + var eventQueue = new AgentEventQueue(); + var events = new EventCollector(); + var readerTask = ReadEventsAsync(eventQueue, events); + await Assert.ThrowsAsync(() => + handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", ContextId = "ctx-cont", + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + cts.Token)); + eventQueue.Complete(null); + await readerTask; + + // Assert - SaveSessionAsync was called with CancellationToken.None despite the exception + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-cont"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + + /// + /// Verifies that in the non-streaming path, SaveSessionAsync is called with + /// CancellationToken.None rather than the caller's cancellation token. + /// + [Fact] + public async Task ExecuteAsync_NonStreaming_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Reply")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object); + + using var cts = new CancellationTokenSource(); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx", StreamingResponse = false, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + + /// + /// Verifies that in the streaming path, SaveSessionAsync is called with + /// CancellationToken.None rather than the caller's cancellation token. + /// + [Fact] + public async Task ExecuteAsync_Streaming_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponseUpdate[] updates = [new AgentResponseUpdate(ChatRole.Assistant, "chunk") { ResponseId = "r1" }]; + A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMock(updates), agentSessionStore: mockSessionStore.Object); + + using var cts = new CancellationTokenSource(); + + // Act + var eventQueue = new AgentEventQueue(); + await handler.ExecuteAsync( + new RequestContext + { + TaskId = "", ContextId = "ctx-stream", StreamingResponse = true, + Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + + // Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-stream"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + + /// + /// Verifies that on the continuation path, SaveSessionAsync is called with + /// CancellationToken.None rather than the caller's cancellation token. + /// + [Fact] + public async Task ExecuteAsync_OnContinuation_SavesSessionWithUncancelledTokenAsync() + { + // Arrange + var mockSessionStore = new Mock(); + mockSessionStore + .Setup(x => x.GetSessionAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new TestAgentSession()); + mockSessionStore + .Setup(x => x.SaveSessionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + AgentResponse response = new([new ChatMessage(ChatRole.Assistant, "Done!")]); + A2AAgentHandler handler = CreateHandler(CreateAgentMockWithResponse(response), agentSessionStore: mockSessionStore.Object); + + using var cts = new CancellationTokenSource(); + + // Act + var eventQueue = new AgentEventQueue(); + var events = new EventCollector(); + var readerTask = ReadEventsAsync(eventQueue, events); + await handler.ExecuteAsync( + new RequestContext + { + StreamingResponse = false, + TaskId = "task-1", ContextId = "ctx-cont", + Message = new Message { MessageId = "empty", Role = Role.User, Parts = [] }, + Task = new AgentTask { Id = "task-1", ContextId = "ctx-cont", History = [new Message { Role = Role.User, Parts = [new Part { Text = "Hello" }] }] } + }, + eventQueue, + cts.Token); + eventQueue.Complete(null); + await readerTask; + + // Assert - SaveSessionAsync was called with CancellationToken.None, not the caller's token + mockSessionStore.Verify( + x => x.SaveSessionAsync( + It.IsAny(), + It.Is(s => s == "ctx-cont"), + It.IsAny(), + It.Is(ct => ct == CancellationToken.None)), + Times.Once); + } + private static A2AAgentHandler CreateHandler( Mock agentMock, AgentRunMode? runMode = null, @@ -905,6 +1658,68 @@ public sealed class A2AAgentHandlerTests return agentMock; } + private static Mock CreateStreamingAgentMock(IEnumerable updates) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Returns(() => ToAsyncEnumerableAsync(updates)); + + return agentMock; + } + + private static Mock CreateStreamingAgentMockWithOptionsCapture( + Action optionsCallback) + { + Mock agentMock = new() { CallBase = true }; + agentMock.SetupGet(x => x.Name).Returns("TestAgent"); + agentMock + .Protected() + .Setup>("CreateSessionCoreAsync", ItExpr.IsAny()) + .ReturnsAsync(new TestAgentSession()); + agentMock + .Protected() + .Setup>("RunCoreStreamingAsync", + ItExpr.IsAny>(), + ItExpr.IsAny(), + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback, AgentSession?, AgentRunOptions?, CancellationToken>( + (_, _, options, _) => optionsCallback(options)) + .Returns(() => ToAsyncEnumerableAsync([new AgentResponseUpdate(ChatRole.Assistant, "reply") { ResponseId = "r1" }])); + + return agentMock; + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable items) + { + await Task.Yield(); + foreach (var item in items) + { + yield return item; + } + } + + private static async IAsyncEnumerable ToThrowingAsyncEnumerableAsync(Exception exception) + { + await Task.Yield(); + throw exception; + +#pragma warning disable CS0162 // Unreachable code detected - yield is required for async iterator + yield break; +#pragma warning restore CS0162 + } + private static async Task InvokeExecuteAsync(A2AAgentHandler handler, RequestContext context) { var eventQueue = new AgentEventQueue(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs index e26189e9e5..9c7b398644 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Converters/MessageConverterTests.cs @@ -147,4 +147,67 @@ public class MessageConverterTests Assert.Equal("First message", result[0].Text); Assert.Equal("Second message", result[1].Text); } + + [Fact] + public void ToParts_AgentResponseUpdate_WithNoContents_ReturnsEmptyList() + { + // Arrange + var update = new AgentResponseUpdate(); + + // Act + var result = update.ToParts(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void ToParts_AgentResponseUpdate_WithTextContent_ReturnsTextPart() + { + // Arrange + var update = new AgentResponseUpdate(ChatRole.Assistant, "Hello from streaming!"); + + // Act + var result = update.ToParts(); + + // Assert + Assert.Single(result); + Assert.Equal("Hello from streaming!", result[0].Text); + } + + [Fact] + public void ToParts_AgentResponseUpdate_WithMultipleContents_ReturnsAllParts() + { + // Arrange + var update = new AgentResponseUpdate(ChatRole.Assistant, [ + new TextContent("First chunk"), + new TextContent("Second chunk") + ]); + + // Act + var result = update.ToParts(); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("First chunk", result[0].Text); + Assert.Equal("Second chunk", result[1].Text); + } + + [Fact] + public void ToParts_AgentResponseUpdate_WithUnsupportedContent_FiltersOutNulls() + { + // Arrange - FunctionCallContent maps to null Part since it's not a supported A2A content type + var update = new AgentResponseUpdate(ChatRole.Assistant, [ + new TextContent("Supported text"), + new FunctionCallContent("call-1", "myFunction") + ]); + + // Act + var result = update.ToParts(); + + // Assert - only the text part should be returned + Assert.Single(result); + Assert.Equal("Supported text", result[0].Text); + } } From 5fe8941ff92d6f68ca5ee8357aedbb12b3cf8968 Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Thu, 23 Apr 2026 11:52:03 -0700 Subject: [PATCH 42/52] =?UTF-8?q?.NET:=20dotnet:=20Add=20server-side=20Fou?= =?UTF-8?q?ndry=20Toolbox=20support=20and=20fix=20SDK=20beta.4=20br?= =?UTF-8?q?=E2=80=A6=20(#5450)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dotnet: Add server-side Foundry Toolbox support and fix SDK beta.4 breaking changes Add FoundryToolbox and AIProjectClient extensions to Microsoft.Agents.AI.Foundry.Hosting for server-side toolbox tool integration matching Python's FoundryChatClient.get_toolbox() pattern. Tools are fetched from the Foundry project SDK and passed as server-side tools in the Responses API request. New files: - FoundryToolbox.cs: Core implementation using AgentAdministrationClient SDK - AIProjectClientToolboxExtensions.cs: Extension methods on AIProjectClient - Agent_Step25_ToolboxServerSideTools sample with create helper and combine flow - 19 unit tests covering param validation, conversion, sanitization, and extensions SDK breaking changes (Azure.AI.AgentServer.Responses beta.3 -> beta.4): - FunctionToolCallOutputResource renamed to OutputItemFunctionToolCallOutput - AzureAIAgentServerResponsesModelFactory made internal, replaced with direct constructors - ResponseUsage constructor now requires non-null token details parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reuse endpoint variable in CreateSampleToolboxAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: pass endpoint through static local functions to avoid capture Static local functions cannot capture top-level variables. Thread the endpoint parameter through Main, CombineToolboxes, and CreateSampleToolboxAsync. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove unused projectClient param from CreateSampleToolboxAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> * Update dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> * Removing GetToolbocVersion. * Removing tests for GetToolboxVersion * fix: map cached/reasoning token counts in ConvertUsage instead of hardcoding zeros Extract InputTokenDetails.CachedTokenCount and OutputTokenDetails.ReasoningTokenCount from UsageDetails.AdditionalCounts, matching the pattern in AgentResponseExtensions. Also accumulate detail counts when merging with existing usage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: westey <164392973+westey-m@users.noreply.github.com> --- dotnet/Directory.Packages.props | 8 +- dotnet/agent-framework-dotnet.slnx | 1 + ...Agent_Step25_ToolboxServerSideTools.csproj | 17 + .../Program.cs | 148 ++++++++ .../README.md | 46 +++ .../AIProjectClientToolboxExtensions.cs | 57 +++ .../FoundryToolbox.cs | 223 ++++++++++++ .../InputConverter.cs | 4 +- .../OutputConverter.cs | 11 +- ...tFrameworkResponseHandlerTelemetryTests.cs | 6 +- .../AgentFrameworkResponseHandlerTests.cs | 48 ++- .../Hosting/FoundryToolboxTests.cs | 329 ++++++++++++++++++ .../Hosting/InputConverterTests.cs | 16 +- .../Hosting/OutputConverterTests.cs | 2 +- .../Hosting/WorkflowIntegrationTests.cs | 8 +- ...crosoft.Agents.AI.Foundry.UnitTests.csproj | 13 +- .../TestData/ToolboxRecordResponse.json | 5 + .../TestData/ToolboxVersionResponse.json | 11 + .../ToolboxVersionWithDecorationFields.json | 11 + .../TestDataUtil.cs | 18 + 20 files changed, 925 insertions(+), 57 deletions(-) create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs create mode 100644 dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AIProjectClientToolboxExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolbox.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxRecordResponse.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionResponse.json create mode 100644 dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionWithDecorationFields.json diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 8fedb11f7f..fff7b6a7d4 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -22,9 +22,9 @@ - - - + + + @@ -188,4 +188,4 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b2b48ce03e..b9a5240fc9 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -160,6 +160,7 @@ + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj new file mode 100644 index 0000000000..0db6ba9fe6 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs new file mode 100644 index 0000000000..12731f4723 --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Program.cs @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to load a Foundry toolbox and pass its tools as server-side +// tools when creating an agent. The Foundry platform handles tool execution — the agent +// process does not invoke tools locally. + +using System.ClientModel; +using System.ClientModel.Primitives; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Azure.Identity; +using Microsoft.Agents.AI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental API +#pragma warning disable AAIP001 // AgentToolboxes is experimental +#pragma warning disable CS8321 // Local functions may be commented-out alternatives + +// Replace with your own Foundry toolbox name. +const string ToolboxName = "research_toolbox"; +// Used only by CombineToolboxes — swap in a second toolbox you own. +const string SecondToolboxName = "analysis_toolbox"; +// Replace with any question that exercises the tools configured in your toolbox. +const string Query = "Introduce yourself and briefly describe the tools you can use to help me."; + +string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("Set FOUNDRY_PROJECT_ENDPOINT to your Foundry project endpoint."); +string model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +var projectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); + +await Main(projectClient, model, endpoint); +// await CombineToolboxes(projectClient, model, endpoint); + +// --------------------------------------------------------------------------- +// Main: single toolbox +// --------------------------------------------------------------------------- +static async Task Main(AIProjectClient projectClient, string model, string endpoint) +{ + Console.WriteLine("=== Foundry Toolbox Server-Side Tools Example ==="); + + // Comment out if the toolbox already exists in your Foundry project. + await CreateSampleToolboxAsync(ToolboxName, endpoint); + + // Omit the version to resolve the toolbox's current default version at runtime. + var tools = await projectClient.GetToolboxToolsAsync(ToolboxName); + + AIAgent agent = projectClient + .AsAIAgent( + model: model, + instructions: "You are a research assistant. Use the available tools to answer questions.", + tools: tools.ToList()); + + Console.WriteLine($"User: {Query}"); + Console.WriteLine($"Result: {await agent.RunAsync(Query)}\n"); +} + +// --------------------------------------------------------------------------- +// Alternative: combine tools from multiple toolboxes +// --------------------------------------------------------------------------- +static async Task CombineToolboxes(AIProjectClient projectClient, string model, string endpoint) +{ + Console.WriteLine("=== Combine Toolboxes Example ==="); + + // Comment out if the toolboxes already exist in your Foundry project. + await CreateSampleToolboxAsync(ToolboxName, endpoint); + await CreateSampleToolboxAsync(SecondToolboxName, endpoint); + + var toolboxA = await projectClient.GetToolboxToolsAsync(ToolboxName); + var toolboxB = await projectClient.GetToolboxToolsAsync(SecondToolboxName); + + var allTools = toolboxA.Concat(toolboxB).ToList(); + + AIAgent agent = projectClient + .AsAIAgent( + model: model, + instructions: "You are a research assistant. Use all available tools to answer questions.", + tools: allTools); + + Console.WriteLine($"User: {Query}"); + Console.WriteLine($"Combined-toolbox result: {await agent.RunAsync(Query)}\n"); +} + +// --------------------------------------------------------------------------- +// Helper: create (or replace) a sample toolbox so the sample works out-of-the-box +// --------------------------------------------------------------------------- +static async Task CreateSampleToolboxAsync(string name, string endpoint) +{ + // Toolboxes are normally configured in the Foundry portal or a deployment + // script, not the application itself. This helper exists so the sample can + // be run end-to-end without first setting a toolbox up by hand. + + // The Foundry-Features header is currently required for toolbox CRUD operations. + var options = new AgentAdministrationClientOptions(); + options.AddPolicy(new FoundryFeaturesPolicy("Toolboxes=V1Preview"), PipelinePosition.PerCall); + var adminClient = new AgentAdministrationClient( + new Uri(endpoint), + new DefaultAzureCredential(), + options); + var toolboxClient = adminClient.GetAgentToolboxes(); + + // Delete existing toolbox if present (ignore 404). + try + { + await toolboxClient.DeleteToolboxAsync(name); + Console.WriteLine($"Deleted existing toolbox '{name}'"); + } + catch (ClientResultException ex) when (ex.Status == 404) + { + // Toolbox does not exist — nothing to delete. + } + + // Create a fresh version with a single MCP tool. + ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool( + serverLabel: "api-specs", + serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"), + toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval))); + + var created = (await toolboxClient.CreateToolboxVersionAsync( + name: name, + tools: [mcpTool], + description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value; + + Console.WriteLine($"Created toolbox '{created.Name}' v{created.Version} ({created.Tools.Count} tool(s))"); +} + +// --------------------------------------------------------------------------- +// Pipeline policy that adds the Foundry-Features header for toolbox CRUD +// --------------------------------------------------------------------------- +internal sealed class FoundryFeaturesPolicy(string feature) : PipelinePolicy +{ + private const string FeatureHeader = "Foundry-Features"; + + public override void Process(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + ProcessNext(message, pipeline, currentIndex); + } + + public override ValueTask ProcessAsync(PipelineMessage message, IReadOnlyList pipeline, int currentIndex) + { + message.Request.Headers.Add(FeatureHeader, feature); + return ProcessNextAsync(message, pipeline, currentIndex); + } +} diff --git a/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/README.md b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/README.md new file mode 100644 index 0000000000..75c6b3eb9d --- /dev/null +++ b/dotnet/samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/README.md @@ -0,0 +1,46 @@ +# Agent_Step25_ToolboxServerSideTools + +This sample demonstrates loading a named Foundry toolbox and passing its tools as +**server-side tools** when creating an agent via `AsAIAgent()`. + +When tools from a toolbox are passed this way, they are sent as tool definitions in +the Responses API request. The Foundry platform handles tool execution — the agent +process does not invoke tools locally. + +This is the dotnet equivalent of the Python sample: +`python/samples/02-agents/providers/foundry/foundry_chat_client_with_toolbox.py` + +## Prerequisites + +- A Microsoft Foundry project +- `AZURE_AI_PROJECT_ENDPOINT` environment variable set to your Foundry project endpoint +- `AZURE_AI_MODEL_DEPLOYMENT_NAME` environment variable set (defaults to `gpt-5.4-mini`) + +The sample recreates the toolbox on each run, replacing any existing toolbox with +the same name. Comment out the `CreateSampleToolboxAsync` call if you want to keep +an existing toolbox unchanged. + +## How it works + +1. `projectClient.GetToolboxVersionAsync(name)` fetches the toolbox definition from the + Foundry project API (resolving the default version if none is specified) +2. `ToolboxVersion.ToAITools()` converts each tool definition to an `AITool` instance +3. The tools are passed to `AsAIAgent(tools: ...)` which includes them in the Responses + API request as server-side tool definitions + +For a one-liner, use `projectClient.GetToolboxToolsAsync(name)` to fetch and convert in one call. + +## Sample flows + +| Flow | Description | +|------|-------------| +| `Main` (default) | Loads a single toolbox and runs an agent with its tools | +| `CombineToolboxes` | Loads two toolboxes and merges their tools into one agent | + +Uncomment the desired flow in the top-level statements to try each one. + +## Running the sample + +```bash +dotnet run +``` diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AIProjectClientToolboxExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AIProjectClientToolboxExtensions.cs new file mode 100644 index 0000000000..48c50eaedb --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AIProjectClientToolboxExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable OPENAI001 +#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents + +namespace Azure.AI.Projects; + +/// +/// Provides extension methods on for fetching +/// Foundry toolbox definitions as server-side tools. +/// +/// +/// These extensions mirror Python's FoundryChatClient.get_toolbox() pattern, +/// allowing a single call on the project client to retrieve tools ready for use +/// with AsAIAgent(model, instructions, tools: ...). +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class AIProjectClientToolboxExtensions +{ + /// + /// Fetches a toolbox from the Foundry project and returns its tools as instances + /// ready for use as server-side tools in the Responses API. + /// + /// The to use. Cannot be . + /// The name of the toolbox to fetch. + /// + /// The specific toolbox version to fetch. When , the toolbox's + /// default version is resolved automatically. + /// + /// A token to monitor for cancellation requests. + /// A read-only list of instances from the toolbox. + /// + /// Thrown when or is . + /// + public static async Task> GetToolboxToolsAsync( + this AIProjectClient projectClient, + string name, + string? version = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(projectClient); + Throw.IfNullOrWhitespace(name); + + var toolboxClient = projectClient.AgentAdministrationClient.GetAgentToolboxes(); + var toolboxVersion = await FoundryToolbox.GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false); + return toolboxVersion.ToAITools(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolbox.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolbox.cs new file mode 100644 index 0000000000..19f6f6823a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryToolbox.cs @@ -0,0 +1,223 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Azure.AI.Projects.Agents; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; +using Microsoft.Shared.Diagnostics; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 +#pragma warning disable AAIP001 // AgentToolboxes is experimental in Azure.AI.Projects.Agents +#pragma warning disable IL2026 // ModelReaderWriter.Read uses reflection; suppressed for Azure SDK model types. +#pragma warning disable IL3050 // ModelReaderWriter.Read requires dynamic code; suppressed for Azure SDK model types. + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Provides methods for fetching Foundry toolbox definitions and converting their tools +/// to instances for use as server-side tools in the Responses API. +/// +/// +/// +/// When tools from a toolbox are passed to a Foundry agent (e.g. via AsAIAgent(model, instructions, tools: ...)), +/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform +/// handles tool execution — the agent process does not invoke tools locally. +/// +/// +/// This is the dotnet equivalent of Python's FoundryChatClient.get_toolbox() pattern. +/// +/// +[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)] +public static class FoundryToolbox +{ + /// + /// Fetches a toolbox version from the Foundry project and returns the raw SDK . + /// + /// The Foundry project endpoint URI. + /// The authentication credential used to access the Foundry project. + /// The name of the toolbox to fetch. + /// + /// The specific toolbox version to fetch. When , the toolbox's + /// default version is resolved automatically (requires an additional API call). + /// + /// A token to monitor for cancellation requests. + /// The containing tool definitions. + /// + /// Thrown when , , or is . + /// + /// Thrown when the Foundry project API returns an error. + public static async Task GetToolboxVersionAsync( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + string name, + string? version = null, + CancellationToken cancellationToken = default) + { + Throw.IfNull(projectEndpoint); + Throw.IfNull(credential); + Throw.IfNullOrWhitespace(name); + + var toolboxClient = CreateToolboxClient(projectEndpoint, credential); + return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false); + } + + /// + /// Fetches a toolbox from the Foundry project and returns its tools as instances + /// ready for use as server-side tools in the Responses API. + /// + /// The Foundry project endpoint URI. + /// The authentication credential used to access the Foundry project. + /// The name of the toolbox to fetch. + /// + /// The specific toolbox version to fetch. When , the toolbox's + /// default version is resolved automatically. + /// + /// A token to monitor for cancellation requests. + /// A read-only list of instances from the toolbox. + /// + /// Thrown when , , or is . + /// + /// Thrown when the Foundry project API returns an error. + public static async Task> GetToolsAsync( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + string name, + string? version = null, + CancellationToken cancellationToken = default) + { + var toolboxVersion = await GetToolboxVersionAsync(projectEndpoint, credential, name, version, cancellationToken).ConfigureAwait(false); + return toolboxVersion.ToAITools(); + } + + /// + /// Converts the tools in a to instances + /// suitable for use as server-side tools in the Responses API. + /// + /// The toolbox version whose tools to convert. + /// A read-only list of instances. + /// Thrown when is . + /// + /// + /// Each in the toolbox is cast to + /// and converted via AsAITool(). Non-function hosted tools (MCP, web_search, + /// code_interpreter, etc.) are included as server-side tool definitions — the Foundry + /// platform handles their execution. + /// + /// + /// Non-function tools are sanitized to remove decoration fields (name, description) + /// that the toolbox API returns but the Responses API rejects. + /// + /// + public static IReadOnlyList ToAITools(this ToolboxVersion toolboxVersion) + { + Throw.IfNull(toolboxVersion); + + if (toolboxVersion.Tools?.Any() != true) + { + return []; + } + + return toolboxVersion.Tools + .Select(SanitizeAndConvert) + .ToList(); + } + + #region Internal helpers (visible to unit tests via InternalsVisibleTo) + + /// + /// Sanitizes a by removing decoration fields that the + /// toolbox API returns but the Responses API rejects, then converts to . + /// + /// + /// The Azure AI Projects toolbox API may return name and description on + /// hosted tool objects (MCP, code_interpreter, file_search, etc.). The Responses API + /// rejects at least name with "Unknown parameter: 'tools[0].name'". We strip + /// these decoration fields for non-function tools. Function tools keep them since + /// name and description are expected parts of the function schema. + /// + internal static AITool SanitizeAndConvert(ProjectsAgentTool tool) + { + var toolJson = ModelReaderWriter.Write(tool, new ModelReaderWriterOptions("J")); + var node = JsonNode.Parse(toolJson.ToString()); + if (node is not JsonObject obj) + { + return ((ResponseTool)tool).AsAITool(); + } + + var toolType = obj["type"]?.GetValue(); + + // Function tools need name/description — don't strip + if (toolType is "function" or "custom") + { + return ((ResponseTool)tool).AsAITool(); + } + + // Strip decoration fields that the Responses API rejects + bool modified = false; + modified |= obj.Remove("name"); + modified |= obj.Remove("description"); + + if (!modified) + { + return ((ResponseTool)tool).AsAITool(); + } + + var sanitizedJson = obj.ToJsonString(); + var sanitizedTool = ModelReaderWriter.Read(BinaryData.FromString(sanitizedJson))!; + return sanitizedTool.AsAITool(); + } + + internal static async Task GetToolboxVersionAsync( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + string name, + string? version, + AgentAdministrationClientOptions? clientOptions, + CancellationToken cancellationToken) + { + Throw.IfNull(projectEndpoint); + Throw.IfNull(credential); + Throw.IfNullOrWhitespace(name); + + var toolboxClient = CreateToolboxClient(projectEndpoint, credential, clientOptions); + return await GetToolboxVersionCoreAsync(toolboxClient, name, version, cancellationToken).ConfigureAwait(false); + } + + internal static AgentToolboxes CreateToolboxClient( + Uri projectEndpoint, + AuthenticationTokenProvider credential, + AgentAdministrationClientOptions? clientOptions = null) + { + clientOptions ??= new AgentAdministrationClientOptions(); + var adminClient = new AgentAdministrationClient(projectEndpoint, credential, clientOptions); + return adminClient.GetAgentToolboxes(); + } + + internal static async Task GetToolboxVersionCoreAsync( + AgentToolboxes toolboxClient, + string name, + string? version, + CancellationToken cancellationToken) + { + if (version is null) + { + var record = await toolboxClient.GetToolboxAsync(name, cancellationToken).ConfigureAwait(false); + version = record.Value.DefaultVersion + ?? throw new InvalidOperationException($"Toolbox '{name}' does not have a default version. Specify an explicit version."); + } + + var result = await toolboxClient.GetToolboxVersionAsync(name, version, cancellationToken).ConfigureAwait(false); + return result.Value; + } + + #endregion +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index cc97049ae9..a65b79a747 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -237,7 +237,7 @@ internal static class InputConverter { OutputItemMessage msg => ConvertOutputItemMessageToChat(msg), OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall), - FunctionToolCallOutputResource funcOutput => ConvertFunctionToolCallOutputResource(funcOutput), + OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput), OutputItemReasoningItem => null, _ => null }; @@ -332,7 +332,7 @@ internal static class InputConverter [new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]); } - private static ChatMessage ConvertFunctionToolCallOutputResource(FunctionToolCallOutputResource funcOutput) + private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput) { return new ChatMessage( ChatRole.Tool, diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 58ba989ebf..2d7728361e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -251,16 +251,25 @@ internal static class OutputConverter var outputTokens = details.OutputTokenCount ?? 0; var totalTokens = details.TotalTokenCount ?? 0; + var cachedTokens = details.AdditionalCounts?.TryGetValue("InputTokenDetails.CachedTokenCount", out var cached) ?? false + ? cached : 0; + var reasoningTokens = details.AdditionalCounts?.TryGetValue("OutputTokenDetails.ReasoningTokenCount", out var reasoning) ?? false + ? reasoning : 0; + if (existing is not null) { inputTokens += existing.InputTokens; outputTokens += existing.OutputTokens; totalTokens += existing.TotalTokens; + cachedTokens += existing.InputTokensDetails?.CachedTokens ?? 0; + reasoningTokens += existing.OutputTokensDetails?.ReasoningTokens ?? 0; } - return AzureAIAgentServerResponsesModelFactory.ResponseUsage( + return new ResponseUsage( inputTokens: inputTokens, + inputTokensDetails: new ResponseUsageInputTokensDetails(cachedTokens), outputTokens: outputTokens, + outputTokensDetails: new ResponseUsageOutputTokensDetails(reasoningTokens), totalTokens: totalTokens); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs index 9b17fa9fae..48daf490ee 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTelemetryTests.cs @@ -164,10 +164,8 @@ public class AgentFrameworkResponseHandlerTelemetryTests private static (CreateResponse request, ResponseContext context) BuildRequest(string? agentKey = null) { var request = agentKey is null - ? AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test") - : AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference(agentKey)); + ? new CreateResponse { Model = "test" } + : new CreateResponse { Model = "test", AgentReference = new AgentReference(agentKey) }; request.Input = BinaryData.FromObjectAsJson(new[] { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs index 75a495f05b..d7fc8fc884 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/AgentFrameworkResponseHandlerTests.cs @@ -34,7 +34,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -72,9 +72,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference("my-agent")); + var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-agent") }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -109,7 +107,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -158,7 +156,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-agent"); + var request = new CreateResponse { Model = "my-agent" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -195,7 +193,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + var request = new CreateResponse { Model = "" }; var metadata = new Metadata(); metadata.AdditionalProperties["entity_id"] = "entity-agent"; request.Metadata = metadata; @@ -235,9 +233,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference("nonexistent-agent")); + var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("nonexistent-agent") }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -272,9 +268,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference("missing-agent")); + var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("missing-agent") }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -308,7 +302,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: ""); + var request = new CreateResponse { Model = "" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -342,7 +336,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -387,7 +381,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -435,7 +429,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -478,7 +472,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -517,9 +511,11 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - instructions: "You are a helpful assistant."); + var request = new CreateResponse + { + Model = "test", + Instructions = "You are a helpful assistant.", + }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -557,7 +553,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -598,9 +594,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference("agent-2")); + var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("agent-2") }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -637,7 +631,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", @@ -674,7 +668,7 @@ public class AgentFrameworkResponseHandlerTests var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = BinaryData.FromObjectAsJson(new[] { new { type = "message", id = "msg_1", status = "completed", role = "user", diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxTests.cs new file mode 100644 index 0000000000..3f0a8a54dd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/FoundryToolboxTests.cs @@ -0,0 +1,329 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Azure.AI.Projects; +using Azure.AI.Projects.Agents; +using Microsoft.Agents.AI.Foundry.Hosting; +using Microsoft.Extensions.AI; + +#pragma warning disable OPENAI001 +#pragma warning disable AAIP001 + +namespace Microsoft.Agents.AI.Foundry.UnitTests; + +/// +/// Unit tests for the class. +/// +public class FoundryToolboxTests +{ + private static readonly Uri s_testEndpoint = new("https://test.services.ai.azure.com/api/projects/test-project"); + + #region Parameter validation tests + + [Fact] + public async Task GetToolboxVersionAsync_NullEndpoint_ThrowsAsync() + { + await Assert.ThrowsAsync(() => + FoundryToolbox.GetToolboxVersionAsync( + projectEndpoint: null!, + credential: new FakeAuthenticationTokenProvider(), + name: "test-toolbox")); + } + + [Fact] + public async Task GetToolboxVersionAsync_NullCredential_ThrowsAsync() + { + await Assert.ThrowsAsync(() => + FoundryToolbox.GetToolboxVersionAsync( + projectEndpoint: s_testEndpoint, + credential: null!, + name: "test-toolbox")); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetToolboxVersionAsync_InvalidName_ThrowsAsync(string? name) + { + await Assert.ThrowsAnyAsync(() => + FoundryToolbox.GetToolboxVersionAsync( + projectEndpoint: s_testEndpoint, + credential: new FakeAuthenticationTokenProvider(), + name: name!)); + } + + [Fact] + public async Task GetToolsAsync_NullEndpoint_ThrowsAsync() + { + await Assert.ThrowsAsync(() => + FoundryToolbox.GetToolsAsync( + projectEndpoint: null!, + credential: new FakeAuthenticationTokenProvider(), + name: "test-toolbox")); + } + + [Fact] + public void ToAITools_NullToolboxVersion_Throws() + { + Assert.Throws(() => + FoundryToolbox.ToAITools(null!)); + } + + #endregion + + #region ToAITools conversion tests + + [Fact] + public void ToAITools_EmptyTools_ReturnsEmptyList() + { + var version = ProjectsAgentsModelFactory.ToolboxVersion( + metadata: null, + id: "ver-1", + name: "empty-toolbox", + version: "v1", + description: "Empty", + createdAt: DateTimeOffset.UtcNow, + tools: Array.Empty(), + policies: null); + + var tools = version.ToAITools(); + + Assert.Empty(tools); + } + + [Fact] + public void ToAITools_NullTools_ReturnsEmptyList() + { + var version = ProjectsAgentsModelFactory.ToolboxVersion( + metadata: null, + id: "ver-1", + name: "null-tools-toolbox", + version: "v1", + description: "Null tools", + createdAt: DateTimeOffset.UtcNow, + tools: null, + policies: null); + + var tools = version.ToAITools(); + + Assert.Empty(tools); + } + + [Fact] + public void ToAITools_WithCodeInterpreterTool_ReturnsAITool() + { + var json = TestDataUtil.GetToolboxVersionResponseJson(); + var version = ModelReaderWriter.Read(BinaryData.FromString(json))!; + + var tools = version.ToAITools(); + + Assert.Single(tools); + Assert.IsAssignableFrom(tools[0]); + } + + [Fact] + public void ToAITools_SanitizesDecorationFieldsOnNonFunctionTools() + { + var json = TestDataUtil.GetToolboxVersionWithDecorationFieldsJson(); + var version = ModelReaderWriter.Read(BinaryData.FromString(json))!; + + var tools = version.ToAITools(); + + Assert.Single(tools); + Assert.IsAssignableFrom(tools[0]); + } + + [Fact] + public void SanitizeAndConvert_FunctionTool_PreservesNameAndDescription() + { + const string ToolJson = @"{""type"":""function"",""name"":""get_weather"",""description"":""Get weather"",""parameters"":{""type"":""object"",""properties"":{}}}"; + var tool = ModelReaderWriter.Read(BinaryData.FromString(ToolJson))!; + + var aiTool = FoundryToolbox.SanitizeAndConvert(tool); + + Assert.NotNull(aiTool); + Assert.IsAssignableFrom(aiTool); + } + + [Fact] + public void SanitizeAndConvert_CodeInterpreterWithExtraFields_StripsDecorationFields() + { + const string ToolJson = @"{""type"":""code_interpreter"",""name"":""code_interpreter"",""description"":""Execute code""}"; + var tool = ModelReaderWriter.Read(BinaryData.FromString(ToolJson))!; + + var aiTool = FoundryToolbox.SanitizeAndConvert(tool); + + Assert.NotNull(aiTool); + } + + #endregion + + #region Integration tests with mock HTTP + + [Fact] + public async Task GetToolboxVersionAsync_WithExplicitVersion_FetchesVersionDirectlyAsync() + { + var versionJson = TestDataUtil.GetToolboxVersionResponseJson(); + using var httpHandler = new HttpHandlerAssert((request) => + { + Assert.Contains("/toolboxes/research_tools/versions/v5", request.RequestUri!.PathAndQuery); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(versionJson, Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }; + + var result = await FoundryToolbox.GetToolboxVersionAsync( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + "research_tools", + version: "v5", + clientOptions: clientOptions, + cancellationToken: default); + + Assert.Equal("research_tools", result.Name); + Assert.Equal("v5", result.Version); + Assert.Single(result.Tools); + } + + [Fact] + public async Task GetToolboxVersionAsync_WithoutVersion_ResolvesDefaultThenFetchesAsync() + { + var recordJson = TestDataUtil.GetToolboxRecordResponseJson(); + var versionJson = TestDataUtil.GetToolboxVersionResponseJson(); + var callCount = 0; + + using var httpHandler = new HttpHandlerAssert((request) => + { + callCount++; + var path = request.RequestUri!.PathAndQuery; + + if (!path.Contains("/versions/")) + { + Assert.Contains("/toolboxes/research_tools", path); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(recordJson, Encoding.UTF8, "application/json") + }; + } + + Assert.Contains("/toolboxes/research_tools/versions/v5", path); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(versionJson, Encoding.UTF8, "application/json") + }; + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }; + + var result = await FoundryToolbox.GetToolboxVersionAsync( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + "research_tools", + version: null, + clientOptions: clientOptions, + cancellationToken: default); + + Assert.Equal(2, callCount); + Assert.Equal("research_tools", result.Name); + Assert.Equal("v5", result.Version); + } + + [Fact] + public async Task GetToolboxVersionAsync_ApiError_ThrowsClientResultExceptionAsync() + { + using var httpHandler = new HttpHandlerAssert((_) => + new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent("{\"error\":\"not found\"}", Encoding.UTF8, "application/json") + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }; + + await Assert.ThrowsAsync(() => + FoundryToolbox.GetToolboxVersionAsync( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + "nonexistent-toolbox", + version: "v1", + clientOptions: clientOptions, + cancellationToken: default)); + } + + [Fact] + public async Task GetToolsAsync_ReturnsConvertedAIToolsAsync() + { + var versionJson = TestDataUtil.GetToolboxVersionResponseJson(); + using var httpHandler = new HttpHandlerAssert((_) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(versionJson, Encoding.UTF8, "application/json") + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + var clientOptions = new AgentAdministrationClientOptions { Transport = new HttpClientPipelineTransport(httpClient) }; + + var result = await FoundryToolbox.GetToolboxVersionAsync( + s_testEndpoint, + new FakeAuthenticationTokenProvider(), + "research_tools", + version: "v5", + clientOptions: clientOptions, + cancellationToken: default); + + var tools = result.ToAITools(); + + Assert.Single(tools); + Assert.IsAssignableFrom(tools[0]); + } + + #endregion + + #region AIProjectClient extension tests + + [Fact] + public async Task AIProjectClientExtension_GetToolboxToolsAsync_ReturnsAIToolsAsync() + { + var versionJson = TestDataUtil.GetToolboxVersionResponseJson(); + using var httpHandler = new HttpHandlerAssert((_) => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(versionJson, Encoding.UTF8, "application/json") + }); + +#pragma warning disable CA5399 + using var httpClient = new HttpClient(httpHandler); +#pragma warning restore CA5399 + var clientOptions = new AIProjectClientOptions(); + clientOptions.Transport = new HttpClientPipelineTransport(httpClient); + var client = new AIProjectClient(s_testEndpoint, new FakeAuthenticationTokenProvider(), clientOptions); + + var tools = await client.GetToolboxToolsAsync("research_tools", version: "v5"); + + Assert.Single(tools); + Assert.IsAssignableFrom(tools[0]); + } + + #endregion +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs index e2f6159a6e..f10a015c4b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/InputConverterTests.cs @@ -2,7 +2,6 @@ using System; using System.Linq; -using Azure.AI.AgentServer.Responses; using Azure.AI.AgentServer.Responses.Models; using Microsoft.Agents.AI.Foundry.Hosting; using Microsoft.Extensions.AI; @@ -146,11 +145,7 @@ public class InputConverterTests [Fact] public void ConvertToChatOptions_SetsTemperatureAndTopP() { - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - temperature: 0.7, - topP: 0.9, - maxOutputTokens: 1000, - model: "gpt-4o"); + var request = new CreateResponse { Temperature = 0.7, TopP = 0.9, MaxOutputTokens = 1000, Model = "gpt-4o" }; var options = InputConverter.ConvertToChatOptions(request); @@ -211,9 +206,9 @@ public class InputConverterTests } [Fact] - public void ConvertOutputItemsToMessages_FunctionToolCallOutputResource_ReturnsToolMessage() + public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage() { - var funcOutput = new FunctionToolCallOutputResource( + var funcOutput = new OutputItemFunctionToolCallOutput( callId: "call_def", output: BinaryData.FromString("result data")); @@ -229,8 +224,7 @@ public class InputConverterTests [Fact] public void ConvertOutputItemsToMessages_ReasoningItem_ReturnsNull() { - var reasoning = AzureAIAgentServerResponsesModelFactory.OutputItemReasoningItem( - id: "reason_001"); + var reasoning = new OutputItemReasoningItem("reason_001", []); var messages = InputConverter.ConvertOutputItemsToMessages([reasoning]); @@ -661,7 +655,7 @@ public class InputConverterTests [Fact] public void ConvertToChatOptions_ModelId_NotSetFromRequest() { - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "my-model"); + var request = new CreateResponse { Model = "my-model" }; var options = InputConverter.ConvertToChatOptions(request); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs index 876339ea2c..2b8abfefcb 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/OutputConverterTests.cs @@ -20,7 +20,7 @@ public class OutputConverterTests private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() { var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var request = new CreateResponse { Model = "test-model" }; var stream = new ResponseEventStream(mockContext.Object, request); return (stream, mockContext); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs index 05be11c3d8..d87406f815 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Hosting/WorkflowIntegrationTests.cs @@ -160,9 +160,7 @@ public class WorkflowIntegrationTests var sp = services.BuildServiceProvider(); var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse( - model: "test", - agentReference: new AgentReference("my-workflow")); + var request = new CreateResponse { Model = "test", AgentReference = new AgentReference("my-workflow") }; request.Input = CreateUserInput("Test keyed workflow"); var mockContext = CreateMockContext(); @@ -363,7 +361,7 @@ public class WorkflowIntegrationTests var sp = services.BuildServiceProvider(); var handler = new AgentFrameworkResponseHandler(sp, NullLogger.Instance); - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test"); + var request = new CreateResponse { Model = "test" }; request.Input = CreateUserInput(userMessage); var mockContext = CreateMockContext(); @@ -393,7 +391,7 @@ public class WorkflowIntegrationTests private static (ResponseEventStream stream, Mock mockContext) CreateTestStream() { var mockContext = new Mock("resp_" + new string('0', 46)) { CallBase = true }; - var request = AzureAIAgentServerResponsesModelFactory.CreateResponse(model: "test-model"); + var request = new CreateResponse { Model = "test-model" }; var stream = new ResponseEventStream(mockContext.Object, request); return (stream, mockContext); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj index 2cead6029a..2265719711 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj @@ -10,7 +10,7 @@ - + @@ -34,7 +34,7 @@ - + @@ -50,6 +50,15 @@ Always + + Always + + + Always + + + Always + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxRecordResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxRecordResponse.json new file mode 100644 index 0000000000..5208a57262 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxRecordResponse.json @@ -0,0 +1,5 @@ +{ + "id": "tbx-123", + "name": "research_tools", + "default_version": "v5" +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionResponse.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionResponse.json new file mode 100644 index 0000000000..13f686ef8d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionResponse.json @@ -0,0 +1,11 @@ +{ + "metadata": {}, + "id": "tbv-research_tools-v5", + "name": "research_tools", + "version": "v5", + "description": "Example research toolbox", + "created_at": 1775779200, + "tools": [ + { "type": "code_interpreter" } + ] +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionWithDecorationFields.json b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionWithDecorationFields.json new file mode 100644 index 0000000000..b78698bfb0 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestData/ToolboxVersionWithDecorationFields.json @@ -0,0 +1,11 @@ +{ + "metadata": {}, + "id": "tbv-dirty-v1", + "name": "dirty_toolbox", + "version": "v1", + "description": "Toolbox with decoration fields on tools", + "created_at": 1775779200, + "tools": [ + { "type": "code_interpreter", "name": "code_interpreter", "description": "Execute Python code" } + ] +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs index 3460362efd..898e0293c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.UnitTests/TestDataUtil.cs @@ -14,6 +14,9 @@ internal static class TestDataUtil private static readonly string s_agentResponseJson = File.ReadAllText("TestData/AgentResponse.json"); private static readonly string s_agentVersionResponseJson = File.ReadAllText("TestData/AgentVersionResponse.json"); private static readonly string s_openAIDefaultResponseJson = File.ReadAllText("TestData/OpenAIDefaultResponse.json"); + private static readonly string s_toolboxRecordResponseJson = File.ReadAllText("TestData/ToolboxRecordResponse.json"); + private static readonly string s_toolboxVersionResponseJson = File.ReadAllText("TestData/ToolboxVersionResponse.json"); + private static readonly string s_toolboxVersionWithDecorationFieldsJson = File.ReadAllText("TestData/ToolboxVersionWithDecorationFields.json"); private const string AgentDefinitionPlaceholder = "\"agent-definition-placeholder\""; @@ -162,4 +165,19 @@ internal static class TestDataUtil } return json; } + + /// + /// Gets the toolbox record response JSON. + /// + public static string GetToolboxRecordResponseJson() => s_toolboxRecordResponseJson; + + /// + /// Gets the toolbox version response JSON. + /// + public static string GetToolboxVersionResponseJson() => s_toolboxVersionResponseJson; + + /// + /// Gets the toolbox version response JSON with decoration fields on tools. + /// + public static string GetToolboxVersionWithDecorationFieldsJson() => s_toolboxVersionWithDecorationFieldsJson; } From b084d0461d74ed282e4d5431e9581e9371aece32 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:19:37 +0900 Subject: [PATCH 43/52] Python: (foundry): stop emitting [TOOLBOXES] warning for every FoundryChatClient call (#5440) * Python: Foundry: make response tool sanitizer internal, drop TOOLBOXES warning sanitize_foundry_response_tool runs on every tool passed to the Foundry Responses API, so its @experimental(TOOLBOXES) decorator was emitting a [TOOLBOXES] ExperimentalWarning for any FoundryChatClient call, even when no toolbox was involved. The function isn't in __all__ and has no external callers. Rename to _sanitize_foundry_response_tool and drop the decorator; the actual toolbox-facing public helpers remain gated. * Python: Foundry: silence pyright on intentional cross-module private import --- python/packages/foundry/agent_framework_foundry/_agent.py | 4 ++-- .../packages/foundry/agent_framework_foundry/_chat_client.py | 4 ++-- python/packages/foundry/agent_framework_foundry/_tools.py | 3 +-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 0c7f93ba1f..626881b3ac 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -34,7 +34,7 @@ from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from ._tools import sanitize_foundry_response_tool +from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -321,7 +321,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] surface. """ response_tools = super()._prepare_tools_for_openai(tools) - return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: """Extract system/developer messages as instructions for Azure AI. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 735ba9fb57..353268a129 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -33,7 +33,7 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential -from ._tools import fetch_toolbox, sanitize_foundry_response_tool +from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -235,7 +235,7 @@ class RawFoundryChatClient( # type: ignore[misc] them downstream. """ response_tools = super()._prepare_tools_for_openai(tools) - return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] async def configure_azure_monitor( self, diff --git a/python/packages/foundry/agent_framework_foundry/_tools.py b/python/packages/foundry/agent_framework_foundry/_tools.py index 4c5956fcfe..40b8bf0905 100644 --- a/python/packages/foundry/agent_framework_foundry/_tools.py +++ b/python/packages/foundry/agent_framework_foundry/_tools.py @@ -155,8 +155,7 @@ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None: ) -@experimental(feature_id=ExperimentalFeature.TOOLBOXES) -def sanitize_foundry_response_tool(tool_item: Any) -> Any: +def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction] """Return a Responses-API-safe tool payload for Foundry hosted tools. Reconciles known mismatches between toolbox reads and the Responses API: From 0989e68d1cee44f07c6b0370106a0a1ccefb10c4 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Thu, 23 Apr 2026 16:40:38 -0700 Subject: [PATCH 44/52] Python: Fix user agent prefix (#5455) * Fix hosting user agent missing * Fix other providers * Add more tests * comments * Fix tests --- .../_bedrock_client.py | 4 +- .../agent_framework_anthropic/_chat_client.py | 6 +- .../_foundry_client.py | 6 +- .../_vertex_client.py | 4 +- .../tests/test_anthropic_provider_clients.py | 11 +- .../_context_provider.py | 12 +- .../_checkpoint_storage.py | 4 +- .../_history_provider.py | 5 +- .../agent_framework_bedrock/_chat_client.py | 4 +- .../_embedding_client.py | 4 +- .../core/agent_framework/_telemetry.py | 78 +++++-- .../core/tests/core/test_telemetry.py | 219 ++++++++++++++---- .../foundry/agent_framework_foundry/_agent.py | 4 +- .../agent_framework_foundry/_chat_client.py | 4 +- .../_memory_provider.py | 4 +- .../tests/foundry/test_foundry_chat_client.py | 4 +- .../foundry/test_foundry_memory_provider.py | 5 +- .../_invocations.py | 8 - .../_responses.py | 26 +-- .../agent_framework_gemini/_chat_client.py | 4 +- .../agent_framework_purview/_client.py | 4 +- 21 files changed, 290 insertions(+), 130 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py index 8b7b6bd71b..6c4c32738c 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_bedrock_client.py @@ -6,13 +6,13 @@ from collections.abc import Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import AsyncAnthropicBedrock @@ -94,7 +94,7 @@ class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[A aws_profile=settings.get("aws_profile"), aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None, base_url=settings.get("anthropic_bedrock_base_url"), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 6e5e5f14a6..53d6d6a65e 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -8,7 +8,6 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc from typing import Any, ClassVar, Final, Generic, Literal, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, Annotation, BaseChatClient, ChatAndFunctionMiddlewareTypes, @@ -28,6 +27,7 @@ from agent_framework import ( tool, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework._tools import SHELL_TOOL_KIND_VALUE from agent_framework._types import _get_data_bytes_as_str # type: ignore from agent_framework.observability import ChatTelemetryLayer @@ -332,7 +332,7 @@ class RawAnthropicClient( anthropic_client = AsyncAnthropic( api_key=api_key_secret.get_secret_value(), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) # Initialize parent @@ -604,7 +604,7 @@ class RawAnthropicClient( run_options["betas"] = self._prepare_betas(options) # extra headers - run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT} + run_options["extra_headers"] = {"User-Agent": get_user_agent()} # Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user) if user := run_options.pop("user", None): diff --git a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py index f5fe37296f..997aac0e44 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_foundry_client.py @@ -6,13 +6,13 @@ from collections.abc import Awaitable, Callable, Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import AsyncAnthropicFoundry @@ -91,14 +91,14 @@ class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[A base_url=base_url_setting, api_key=api_key_value, azure_ad_token_provider=azure_ad_token_provider, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) else: anthropic_client = AsyncAnthropicFoundry( resource=resource_setting, api_key=api_key_value, azure_ad_token_provider=azure_ad_token_provider, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py index 73b16af4a7..7af22be0f0 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_vertex_client.py @@ -6,13 +6,13 @@ from collections.abc import Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionInvocationConfiguration, FunctionInvocationLayer, ) from agent_framework._settings import load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from anthropic import NOT_GIVEN, AsyncAnthropicVertex @@ -89,7 +89,7 @@ class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[An access_token=access_token, credentials=credentials, base_url=settings.get("anthropic_vertex_base_url"), - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) super().__init__( diff --git a/python/packages/anthropic/tests/test_anthropic_provider_clients.py b/python/packages/anthropic/tests/test_anthropic_provider_clients.py index d076062fe6..0fb208b5e4 100644 --- a/python/packages/anthropic/tests/test_anthropic_provider_clients.py +++ b/python/packages/anthropic/tests/test_anthropic_provider_clients.py @@ -3,7 +3,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from agent_framework_anthropic import ( @@ -61,7 +62,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path) resource="test-resource", api_key="test-key", azure_ad_token_provider=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -85,7 +86,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings( base_url="https://test-resource.services.ai.azure.com/anthropic/", api_key="test-key", azure_ad_token_provider=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -130,7 +131,7 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non aws_profile=None, aws_session_token=None, base_url=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) @@ -152,5 +153,5 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None access_token=None, credentials=None, base_url=None, - default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, + default_headers={"User-Agent": get_user_agent()}, ) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index de3c190e66..5a0b79f29d 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -14,7 +14,6 @@ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentSession, Annotation, Content, @@ -25,6 +24,7 @@ from agent_framework import ( SupportsGetEmbeddings, load_settings, ) +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import SettingNotFoundError from azure.core.credentials import AzureKeyCredential, TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -535,7 +535,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, index_name=self.index_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._index_client: SearchIndexClient | None = None @@ -544,7 +544,7 @@ class AzureAISearchContextProvider(ContextProvider): self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._knowledge_base_initialized = False @@ -640,7 +640,7 @@ class AzureAISearchContextProvider(ContextProvider): self._index_client = SearchIndexClient( endpoint=self.endpoint, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) if not self.index_name: logger.warning("Cannot auto-discover vector field: index_name is not set.") @@ -740,7 +740,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) self._knowledge_base_initialized = True return @@ -802,7 +802,7 @@ class AzureAISearchContextProvider(ContextProvider): endpoint=self.endpoint, knowledge_base_name=knowledge_base_name, credential=self.credential, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) async def _agentic_search(self, messages: list[Message]) -> list[Message]: diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py index 496d95d7c3..915eee432b 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_checkpoint_storage.py @@ -7,8 +7,8 @@ from __future__ import annotations import logging from typing import Any, TypedDict -from agent_framework import AGENT_FRAMEWORK_USER_AGENT from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from agent_framework.exceptions import WorkflowCheckpointException @@ -194,7 +194,7 @@ class CosmosCheckpointStorage: self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] - user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + user_agent_suffix=get_user_agent(), ) self._owns_client = True diff --git a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py index d13f285249..62a83e6a0f 100644 --- a/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py +++ b/python/packages/azure-cosmos/agent_framework_azure_cosmos/_history_provider.py @@ -10,9 +10,10 @@ import uuid from collections.abc import Sequence from typing import Any, ClassVar, TypedDict -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message +from agent_framework import Message from agent_framework._sessions import HistoryProvider from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential from azure.cosmos import PartitionKey @@ -121,7 +122,7 @@ class CosmosHistoryProvider(HistoryProvider): self._cosmos_client = CosmosClient( url=settings["endpoint"], # type: ignore[arg-type] credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr] - user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT, + user_agent_suffix=get_user_agent(), ) self._owns_client = True diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 3606cdf26b..8e05591372 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -13,7 +13,6 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict from uuid import uuid4 from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseChatClient, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -31,6 +30,7 @@ from agent_framework import ( validate_tool_mode, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientInvalidResponseException from agent_framework.observability import ChatTelemetryLayer from boto3.session import Session as Boto3Session @@ -299,7 +299,7 @@ class BedrockChatClient( self._bedrock_client = session.client( "bedrock-runtime", region_name=region, - config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), + config=BotoConfig(user_agent_extra=get_user_agent()), ) super().__init__( diff --git a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py index 99250b8248..52f5126e3d 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_embedding_client.py @@ -11,7 +11,6 @@ from collections.abc import Sequence from typing import Any, ClassVar, Generic, TypedDict from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseEmbeddingClient, Embedding, EmbeddingGenerationOptions, @@ -20,6 +19,7 @@ from agent_framework import ( UsageDetails, load_settings, ) +from agent_framework._telemetry import get_user_agent from agent_framework.observability import EmbeddingTelemetryLayer from boto3.session import Session as Boto3Session from botocore.client import BaseClient @@ -140,7 +140,7 @@ class RawBedrockEmbeddingClient( self._bedrock_client = boto3_session.client( "bedrock-runtime", region_name=region_name or resolved_region, - config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), + config=BotoConfig(user_agent_extra=get_user_agent()), ) self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess] diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index a3b0f74146..f7ca2ce030 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -4,9 +4,6 @@ from __future__ import annotations import logging import os -from collections.abc import Generator -from contextlib import contextmanager -from contextvars import ContextVar from typing import Any, Final from . import __version__ as version_info @@ -29,34 +26,73 @@ USER_AGENT_KEY: Final[str] = "User-Agent" HTTP_USER_AGENT: Final[str] = "agent-framework-python" AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type] -_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=()) +# This environment variable is reserved by the Foundry hosting environment to +# indicate that the agent is running in a hosted environment. +_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT" +# This prefix is added to the user agent string when the agent is running in a hosted environment. +_HOSTED_USER_AGENT_PREFIX = "foundry-hosting" + +_user_agent_prefixes: set[str] = set() +_hosted_env_detected: bool = False -@contextmanager -def user_agent_prefix(prefix: str) -> Generator[None]: - """Context manager that adds a prefix to the user agent string for the current scope. +def _add_user_agent_prefix(prefix: str) -> None: + """Permanently add a prefix to the user agent string. - This is useful for upstream layers that want to identify themselves in telemetry - for the duration of a request without permanently mutating global state. + This is used by hosting layers to identify themselves in telemetry. + Once added, the prefix applies to all subsequent user agent strings. Args: prefix: The prefix to add (e.g. "foundry-hosting"). """ - current = _user_agent_prefixes.get() - token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None + if prefix: + _user_agent_prefixes.add(prefix) + + +def _detect_hosted_environment() -> None: + """Detect if running in a hosted environment and add the user agent prefix. + + Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back + to checking whether the agent server SDK is installed (via + ``importlib.util.find_spec``) before importing it, to avoid unnecessary + import overhead for non-hosted scenarios. + """ + global _hosted_env_detected + if _hosted_env_detected: + return + _hosted_env_detected = True + + env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR) + if env_value is not None: + # Env var exists — trust its value and skip the fallback. + if env_value: + _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + return + + # Env var not set — fall back to AgentConfig as a second layer of defense. + # Use find_spec to avoid the cost of a full import when the SDK is not installed. + import importlib.util + try: - yield - finally: - if token is not None: - _user_agent_prefixes.reset(token) + if importlib.util.find_spec("azure.ai.agentserver.core") is None: + return + except (ModuleNotFoundError, ValueError): + return + try: + from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports] + + if AgentConfig.from_env().is_hosted: + _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + except (ImportError, AttributeError): + pass -def _get_user_agent() -> str: - """Return the full user agent string including any context-scoped prefixes.""" - prefixes = _user_agent_prefixes.get() - if not prefixes: +def get_user_agent() -> str: + """Return the full user agent string including any registered prefixes.""" + _detect_hosted_environment() + if not _user_agent_prefixes: return AGENT_FRAMEWORK_USER_AGENT - return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}" + return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}" def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]: @@ -89,7 +125,7 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) """ if not IS_TELEMETRY_ENABLED: return headers or {} - user_agent = _get_user_agent() + user_agent = get_user_agent() if not headers: return {USER_AGENT_KEY: user_agent} headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent diff --git a/python/packages/core/tests/core/test_telemetry.py b/python/packages/core/tests/core/test_telemetry.py index 1ba1df1dde..b0b01706ef 100644 --- a/python/packages/core/tests/core/test_telemetry.py +++ b/python/packages/core/tests/core/test_telemetry.py @@ -1,14 +1,21 @@ # Copyright (c) Microsoft. All rights reserved. -from unittest.mock import patch +import os +from unittest.mock import MagicMock, patch +import agent_framework._telemetry as _telemetry_mod from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, USER_AGENT_KEY, USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, prepend_agent_framework_to_user_agent, ) -from agent_framework._telemetry import user_agent_prefix +from agent_framework._telemetry import ( + _FOUNDRY_HOSTING_ENV_VAR, + _HOSTED_USER_AGENT_PREFIX, + _add_user_agent_prefix, + _detect_hosted_environment, +) # region Test constants @@ -83,7 +90,7 @@ def test_prepend_to_empty_headers(): def test_prepend_to_empty_dict(): """Test prepending to empty headers dict.""" - headers = {} + headers: dict[str, str] = {} result = prepend_agent_framework_to_user_agent(headers) assert "User-Agent" in result @@ -99,54 +106,184 @@ def test_modifies_original_dict(): assert "User-Agent" in headers -# region Test user_agent_prefix context manager +# region Test _add_user_agent_prefix -def test_user_agent_prefix_adds_prefix(): - """Test that the context manager adds a prefix within its scope.""" - with user_agent_prefix("test-host"): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"].startswith("test-host/") - assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"] - - # Prefix is removed after exiting the context +def test_add_user_agent_prefix_adds_prefix(): + """Test that _add_user_agent_prefix permanently adds a prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("test-host") result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + assert result["User-Agent"].startswith("test-host/") + assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"] + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_ignores_duplicates(): - """Test that duplicate prefixes are not added within nested scopes.""" - with user_agent_prefix("test-host"), user_agent_prefix("test-host"): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"].count("test-host") == 1 +def test_add_user_agent_prefix_ignores_duplicates(): + """Test that duplicate prefixes are not added.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("test-host") + _add_user_agent_prefix("test-host") + result = prepend_agent_framework_to_user_agent() + assert result["User-Agent"].count("test-host") == 1 + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_ignores_empty(): +def test_add_user_agent_prefix_ignores_empty(): """Test that empty strings are not added as prefixes.""" - with user_agent_prefix(""): - result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT - - -def test_user_agent_prefix_restores_on_exit(): - """Test that prefixes are fully restored after the context manager exits.""" - with user_agent_prefix("test-host"): - pass + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("") result = prepend_agent_framework_to_user_agent() assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + _telemetry_mod._user_agent_prefixes.clear() -def test_user_agent_prefix_nesting(): - """Test that nested context managers compose prefixes correctly.""" - with user_agent_prefix("outer"): - with user_agent_prefix("inner"): - result = prepend_agent_framework_to_user_agent() - assert "outer" in result["User-Agent"] - assert "inner" in result["User-Agent"] - # Inner prefix removed - result = prepend_agent_framework_to_user_agent() - assert "outer" in result["User-Agent"] - assert "inner" not in result["User-Agent"] - # Both removed +def test_add_user_agent_prefix_multiple(): + """Test that multiple prefixes compose correctly.""" + _telemetry_mod._user_agent_prefixes.clear() + _add_user_agent_prefix("outer") + _add_user_agent_prefix("inner") result = prepend_agent_framework_to_user_agent() - assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT + assert "outer" in result["User-Agent"] + assert "inner" in result["User-Agent"] + _telemetry_mod._user_agent_prefixes.clear() + + +# region Test _detect_hosted_environment + + +def test_detect_hosted_env_var_truthy_adds_prefix(): + """Test that a truthy FOUNDRY_HOSTING_ENVIRONMENT env var adds the prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_env_var_empty_skips_prefix(): + """Test that an empty FOUNDRY_HOSTING_ENVIRONMENT env var does NOT add the prefix.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: ""}): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_env_var_set_skips_agent_config_fallback(): + """Test that when the env var is set, AgentConfig is never consulted even if import would fail.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + import builtins + + real_import = builtins.__import__ + + def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if "agentserver" in name: + raise AssertionError("AgentConfig should not be imported when env var is set") + return real_import(name, *args, **kwargs) + + with ( + patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "prod"}), + patch("builtins.__import__", side_effect=_block_agentconfig), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def _mock_agent_config(*, is_hosted: bool) -> MagicMock: + """Create a mock azure.ai.agentserver.core module with AgentConfig.""" + mock_config = MagicMock() + mock_config.is_hosted = is_hosted + mock_module = MagicMock() + mock_module.AgentConfig.from_env.return_value = mock_config + return mock_module + + +def test_detect_hosted_fallback_agent_config_is_hosted(): + """Test that AgentConfig fallback adds the prefix when is_hosted is True.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + mock_module = _mock_agent_config(is_hosted=True) + mock_spec = MagicMock() + with ( + patch.dict("os.environ", env, clear=True), + patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}), + patch("importlib.util.find_spec", return_value=mock_spec), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_fallback_agent_config_not_hosted(): + """Test that AgentConfig fallback does NOT add the prefix when is_hosted is False.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + mock_module = _mock_agent_config(is_hosted=False) + mock_spec = MagicMock() + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + with ( + patch.dict("os.environ", env, clear=True), + patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}), + patch("importlib.util.find_spec", return_value=mock_spec), + ): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +def test_detect_hosted_fallback_import_error(): + """Test that ImportError from AgentConfig is silently handled.""" + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR} + with patch.dict("os.environ", env, clear=True): + # The real import may succeed or fail depending on the environment; + # force the ImportError path by making the import raise. + import builtins + + real_import = builtins.__import__ + + def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def] + if "agentserver" in name: + raise ImportError("mocked") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=_block_agentconfig): + _detect_hosted_environment() + assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + + +# region Test module-level auto-detection + + +def test_lazy_detection_on_get_user_agent(): + """Test that get_user_agent() lazily detects the hosted environment. + + Since detection is deferred to the first ``get_user_agent()`` call, + this verifies the prefix is included without any explicit call to + ``_detect_hosted_environment()`` by consumer code. + """ + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False + with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}): + user_agent = _telemetry_mod.get_user_agent() + + assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes + assert user_agent.startswith(f"{_HOSTED_USER_AGENT_PREFIX}/") + + # Clean up + _telemetry_mod._user_agent_prefixes.clear() + _telemetry_mod._hosted_env_detected = False diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 626881b3ac..b473c787e5 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -15,7 +15,6 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequen from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentMiddlewareLayer, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -28,6 +27,7 @@ from agent_framework import ( load_settings, ) from agent_framework._compaction import CompactionStrategy, TokenizerProtocol +from agent_framework._telemetry import get_user_agent from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -190,7 +190,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] project_client_kwargs: dict[str, Any] = { "endpoint": resolved_endpoint, "credential": credential, - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 353268a129..4428d69dc6 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -8,7 +8,6 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, Content, FunctionInvocationConfiguration, @@ -17,6 +16,7 @@ from agent_framework import ( ) from agent_framework._compaction import CompactionStrategy, TokenizerProtocol from agent_framework._feature_stage import ExperimentalFeature, experimental +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient @@ -198,7 +198,7 @@ class RawFoundryChatClient( # type: ignore[misc] project_client_kwargs: dict[str, Any] = { "endpoint": project_endpoint, "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/agent_framework_foundry/_memory_provider.py b/python/packages/foundry/agent_framework_foundry/_memory_provider.py index c49f950b6b..169da4fc85 100644 --- a/python/packages/foundry/agent_framework_foundry/_memory_provider.py +++ b/python/packages/foundry/agent_framework_foundry/_memory_provider.py @@ -14,13 +14,13 @@ from contextlib import AbstractAsyncContextManager from typing import TYPE_CHECKING, Any, ClassVar from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, AgentSession, ContextProvider, Message, SessionContext, load_settings, ) +from agent_framework._telemetry import get_user_agent from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -119,7 +119,7 @@ class FoundryMemoryProvider(ContextProvider): project_client_kwargs: dict[str, Any] = { "endpoint": resolved_endpoint, "credential": credential, # type: ignore[arg-type] - "user_agent": AGENT_FRAMEWORK_USER_AGENT, + "user_agent": get_user_agent(), } if allow_preview is not None: project_client_kwargs["allow_preview"] = allow_preview diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 68e7adc6fb..a7b57029ba 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool -from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException from azure.ai.projects.models import MCPTool as FoundryMCPTool @@ -199,7 +199,7 @@ def test_init_with_project_endpoint_creates_project_client() -> None: assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT assert factory.call_args.kwargs["credential"] is credential assert factory.call_args.kwargs["allow_preview"] is True - assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT + assert factory.call_args.kwargs["user_agent"] == get_user_agent() def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py index 2e25ba38d4..6377dfa602 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py +++ b/python/packages/foundry/tests/foundry/test_foundry_memory_provider.py @@ -7,8 +7,9 @@ import os from unittest.mock import AsyncMock, Mock, patch import pytest -from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message +from agent_framework import AgentResponse, Message from agent_framework._sessions import AgentSession, SessionContext +from agent_framework._telemetry import get_user_agent from agent_framework_foundry._memory_provider import FoundryMemoryProvider @@ -94,7 +95,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc endpoint="https://test.project.endpoint", credential=mock_credential, allow_preview=True, - user_agent=AGENT_FRAMEWORK_USER_AGENT, + user_agent=get_user_agent(), ) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py index b5bf98291b..05105ec768 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py @@ -1,7 +1,6 @@ # Copyright (c) Microsoft. All rights reserved. from agent_framework import AgentSession, BaseAgent, SupportsAgentRun -from agent_framework._telemetry import user_agent_prefix from azure.ai.agentserver.invocations import InvocationAgentServerHost from starlette.requests import Request from starlette.responses import JSONResponse, Response, StreamingResponse @@ -11,8 +10,6 @@ from typing_extensions import Any, AsyncGenerator class InvocationsHostServer(InvocationAgentServerHost): """An invocations server host for an agent.""" - USER_AGENT_PREFIX = "foundry-hosting" - def __init__( self, agent: BaseAgent, @@ -42,11 +39,6 @@ class InvocationsHostServer(InvocationAgentServerHost): async def _handle_invoke(self, request: Request) -> Response: """Invoke the agent with the given request.""" - with user_agent_prefix(self.USER_AGENT_PREFIX): - return await self._handle_invoke_inner(request) - - async def _handle_invoke_inner(self, request: Request) -> Response: - """Core invoke handler logic.""" data = await request.json() session_id: str = request.state.session_id diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index cdd4b34b4f..63bc730e78 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -20,7 +20,6 @@ from agent_framework import ( SupportsAgentRun, WorkflowAgent, ) -from agent_framework._telemetry import user_agent_prefix from azure.ai.agentserver.responses import ( ResponseContext, ResponseEventStream, @@ -90,7 +89,6 @@ logger = logging.getLogger(__name__) class ResponsesHostServer(ResponsesAgentServerHost): """A responses server host for an agent.""" - USER_AGENT_PREFIX = "foundry-hosting" # TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally CHECKPOINT_STORAGE_PATH = "/.checkpoints" @@ -150,37 +148,32 @@ class ResponsesHostServer(ResponsesAgentServerHost): self._is_workflow_agent = True self._agent = agent - self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType] + self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType] @staticmethod def _is_streaming_request(request: CreateResponse) -> bool: """Check if the request is a streaming request.""" return request.stream is not None and request.stream is True - async def _handler( + def _handle_response( self, request: CreateResponse, context: ResponseContext, cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response.""" - with user_agent_prefix(self.USER_AGENT_PREFIX): - async for event in self._handle_inner(request, context, cancellation_signal): - yield event + if self._is_workflow_agent: + # Workflow agents are handled differently because they require checkpoint restoration + return self._handle_workflow_agent(request, context) - async def _handle_inner( + return self._handle_regular_agent(request, context) + + async def _handle_regular_agent( self, request: CreateResponse, context: ResponseContext, - cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: - """Core handler logic.""" - if self._is_workflow_agent: - # Workflow agents are handled differently because they require checkpoint restoration - async for event in self._handle_workflow_agent(request, context, cancellation_signal): - yield event - return - + """Handle the creation of a response for a regular (non-workflow) agent.""" input_text = await context.get_input_text() history = await context.get_history() messages: list[str | Content | Message] = [*_to_messages(history), input_text] @@ -243,7 +236,6 @@ class ResponsesHostServer(ResponsesAgentServerHost): self, request: CreateResponse, context: ResponseContext, - cancellation_signal: asyncio.Event, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response for a workflow agent. diff --git a/python/packages/gemini/agent_framework_gemini/_chat_client.py b/python/packages/gemini/agent_framework_gemini/_chat_client.py index b0fa52a676..59a4033fa2 100644 --- a/python/packages/gemini/agent_framework_gemini/_chat_client.py +++ b/python/packages/gemini/agent_framework_gemini/_chat_client.py @@ -10,7 +10,6 @@ from typing import Any, ClassVar, Generic, cast from uuid import uuid4 from agent_framework import ( - AGENT_FRAMEWORK_USER_AGENT, BaseChatClient, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, @@ -28,6 +27,7 @@ from agent_framework import ( validate_tool_mode, ) from agent_framework._settings import SecretString, load_settings +from agent_framework._telemetry import get_user_agent from agent_framework.observability import ChatTelemetryLayer from google import genai from google.auth.credentials import Credentials @@ -355,7 +355,7 @@ class RawGeminiChatClient( ) client_kwargs: dict[str, Any] = { - "http_options": {"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}}, + "http_options": {"headers": {"x-goog-api-client": get_user_agent()}}, } if configured_vertexai is not None: client_kwargs["vertexai"] = configured_vertexai diff --git a/python/packages/purview/agent_framework_purview/_client.py b/python/packages/purview/agent_framework_purview/_client.py index af5c3f8224..43c8adce4e 100644 --- a/python/packages/purview/agent_framework_purview/_client.py +++ b/python/packages/purview/agent_framework_purview/_client.py @@ -11,7 +11,7 @@ from typing import Any, Literal, TypeVar, Union, overload from uuid import uuid4 import httpx -from agent_framework import AGENT_FRAMEWORK_USER_AGENT +from agent_framework._telemetry import get_user_agent from agent_framework.observability import get_tracer from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -189,7 +189,7 @@ class PurviewClient: payload = model.model_dump(by_alias=True, exclude_none=True, mode="json") request_headers = { "Authorization": f"Bearer {token}", - "User-Agent": AGENT_FRAMEWORK_USER_AGENT, + "User-Agent": get_user_agent(), "Content-Type": "application/json", } if correlation_id: From 932ceddf95f500e0a8a40e574872d66df5965729 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 13:12:34 +0900 Subject: [PATCH 45/52] Python: Fix AG-UI reasoning role and multimodal media parsing to follow specification (#5389) * Fix AG-UI reasoning role and multimodal media value field parsing Fix two spec compliance issues in the AG-UI integration: 1. ReasoningMessageStartEvent now uses role='reasoning' instead of role='assistant', matching the AG-UI specification for reasoning messages. 2. _parse_multimodal_media_part now reads the 'value' field from source dicts (with fallback to 'data' for backward compatibility), matching the current AG-UI InputContentSource specification. Bump ag-ui-protocol dependency from ==0.1.13 to >=0.1.16,<0.2 to pick up the SDK fix that accepts role='reasoning' in ReasoningMessageStartEvent. Fix pre-existing pyright reportMissingImports errors for orjson in sample files, and fix import ordering in foundry-hosted-agents sample. Fixes #5340 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Python: Fix AG-UI reasoning role and multimodal media parsing to follow specification Fixes #5340 * Remove unintended .maf-runtime-ready marker file Address PR review feedback: the .maf-runtime-ready file is not referenced anywhere in the repo and was left over from automation. Fixes #5340 * Python: Fix duplicate AG-UI multimodal 'value' parsing in snapshot path The snapshot normalization path used a second copy of the multimodal source parsing logic that still read the deprecated 'data' field. When clients sent base64 media with source={"type": "base64", "value": ...}, the snapshot event emitted by the server dropped the payload, causing AG-UI-compatible clients to crash on ingest. Extract the shared source-field extraction into _extract_multimodal_source_fields so both _parse_multimodal_media_part and the snapshot _legacy_binary_part stay in sync with the AG-UI spec. Add snapshot-path regression tests covering value-only, value-preferred-over-data, and the legacy data-field fallback. Addresses review feedback on #5389 from @Rickyneer. --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_message_adapters.py | 72 ++++------ .../agent_framework_ag_ui/_run_common.py | 4 +- python/packages/ag-ui/pyproject.toml | 2 +- .../tests/ag_ui/test_message_adapters.py | 135 ++++++++++++++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 33 ++++- python/uv.lock | 8 +- 6 files changed, 204 insertions(+), 50 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py index 5e4fced97c..c4d2e9b2cd 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_message_adapters.py @@ -263,27 +263,21 @@ def _deduplicate_messages(messages: list[Message]) -> list[Message]: return unique_messages -def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: - """Convert a multimodal media part into Agent Framework content.""" - part_type = str(part.get("type", "")).lower() - source = part.get("source") +def _extract_multimodal_source_fields( + part: dict[str, Any], +) -> tuple[str | None, str | None, str | None, str | None]: + """Extract ``(url, data, binary_id, mime_type)`` from an AG-UI multimodal part. - mime_type = cast( - str | None, - part.get("mimeType") - or part.get("mime_type") - or { - "image": "image/*", - "audio": "audio/*", - "video": "video/*", - "document": "application/octet-stream", - "binary": "application/octet-stream", - }.get(part_type, "application/octet-stream"), - ) + Handles both the current AG-UI spec (``source.value`` for base64 payloads) and the + legacy ``source.data`` field for backward compatibility. Returned values are the + raw extracted strings (or ``None`` when absent); callers apply their own defaults. + """ + mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type")) url = cast(str | None, part.get("url") or part.get("uri")) data = cast(str | None, part.get("data")) binary_id = cast(str | None, part.get("id")) + source = part.get("source") if isinstance(source, dict): source_dict = cast(dict[str, Any], source) source_type = str(source_dict.get("type", "")).lower() @@ -294,14 +288,31 @@ def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: if source_type in {"url", "uri"}: url = cast(str | None, source_dict.get("url") or source_dict.get("uri")) elif source_type in {"base64", "data", "binary"}: - data = cast(str | None, source_dict.get("data")) + data = cast(str | None, source_dict.get("value") or source_dict.get("data")) elif source_type in {"id", "file"}: binary_id = cast(str | None, source_dict.get("id")) else: url = cast(str | None, source_dict.get("url") or source_dict.get("uri") or url) - data = cast(str | None, source_dict.get("data") or data) + data = cast(str | None, source_dict.get("value") or source_dict.get("data") or data) binary_id = cast(str | None, source_dict.get("id") or binary_id) + return url, data, binary_id, mime_type + + +def _parse_multimodal_media_part(part: dict[str, Any]) -> Content | None: + """Convert a multimodal media part into Agent Framework content.""" + part_type = str(part.get("type", "")).lower() + url, data, binary_id, mime_type = _extract_multimodal_source_fields(part) + + if not mime_type: + mime_type = { + "image": "image/*", + "audio": "audio/*", + "video": "video/*", + "document": "application/octet-stream", + "binary": "application/octet-stream", + }.get(part_type, "application/octet-stream") + if isinstance(url, str) and url: return Content.from_uri(uri=url, media_type=mime_type) @@ -389,30 +400,7 @@ def _normalize_snapshot_content(content: Any) -> Any: def _legacy_binary_part(part: dict[str, Any]) -> dict[str, Any]: """Convert draft/legacy multimodal parts to AG-UI snapshot binary shape.""" normalized: dict[str, Any] = {"type": "binary"} - - mime_type = cast(str | None, part.get("mimeType") or part.get("mime_type")) - url = cast(str | None, part.get("url") or part.get("uri")) - data = cast(str | None, part.get("data")) - binary_id = cast(str | None, part.get("id")) - - source = part.get("source") - if isinstance(source, dict): - source_part = cast(dict[str, Any], source) - source_mime = source_part.get("mimeType") or source_part.get("mime_type") - if isinstance(source_mime, str) and source_mime: - mime_type = source_mime - - source_type = str(source_part.get("type", "")).lower() - if source_type in {"url", "uri"}: - url = cast(str | None, source_part.get("url") or source_part.get("uri")) - elif source_type in {"base64", "data", "binary"}: - data = cast(str | None, source_part.get("data")) - elif source_type in {"id", "file"}: - binary_id = cast(str | None, source_part.get("id")) - else: - url = cast(str | None, source_part.get("url") or source_part.get("uri") or url) - data = cast(str | None, source_part.get("data") or data) - binary_id = cast(str | None, source_part.get("id") or binary_id) + url, data, binary_id, mime_type = _extract_multimodal_source_fields(part) if isinstance(mime_type, str) and mime_type: normalized["mimeType"] = mime_type diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index 58236cdf0e..b89124ca16 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -596,7 +596,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis events.extend(_close_reasoning_block(flow)) # Open new reasoning block. events.append(ReasoningStartEvent(message_id=message_id)) - events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning")) flow.reasoning_message_id = message_id if text: @@ -613,7 +613,7 @@ def _emit_text_reasoning(content: Content, flow: FlowState | None = None) -> lis else: # No flow -- backward-compatible full sequence per call. events.append(ReasoningStartEvent(message_id=message_id)) - events.append(ReasoningMessageStartEvent(message_id=message_id, role="assistant")) + events.append(ReasoningMessageStartEvent(message_id=message_id, role="reasoning")) if text: events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 0bba0b3006..5c79328794 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.1.1,<2", - "ag-ui-protocol==0.1.13", + "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" ] diff --git a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py index 9508b53085..69f7b7bdb3 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py +++ b/python/packages/ag-ui/tests/ag_ui/test_message_adapters.py @@ -536,6 +536,77 @@ def test_agui_snapshot_format_preserves_multimodal_content(): assert content_parts[1]["url"] == "https://example.com/image.png" +def test_agui_snapshot_format_reads_base64_value_field(): + """Snapshot normalization reads the spec 'value' field for base64 sources.""" + payload = base64.b64encode(b"abc").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": {"type": "base64", "value": payload, "mimeType": "image/png"}, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["type"] == "binary" + assert binary_part["mimeType"] == "image/png" + assert binary_part["data"] == payload + + +def test_agui_snapshot_format_base64_value_preferred_over_data(): + """Snapshot normalization prefers 'value' when both 'value' and 'data' are set.""" + value_payload = base64.b64encode(b"new-spec").decode("utf-8") + data_payload = base64.b64encode(b"legacy").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "value": value_payload, + "data": data_payload, + "mimeType": "image/png", + }, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["data"] == value_payload + + +def test_agui_snapshot_format_base64_data_field_backward_compat(): + """Snapshot normalization still reads the legacy 'data' field when 'value' is absent.""" + payload = base64.b64encode(b"legacy").decode("utf-8") + normalized = agui_messages_to_snapshot_format( + [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": {"type": "base64", "data": payload, "mimeType": "image/png"}, + }, + ], + } + ] + ) + + binary_part = normalized[0]["content"][0] + assert binary_part["data"] == payload + + def test_agui_with_tool_calls_to_agent_framework(): """Assistant message with tool_calls is converted to FunctionCallContent.""" agui_msg = { @@ -1760,3 +1831,67 @@ class TestReasoningRoundTrip: assert "First answer" in texts assert "Follow-up question" in texts assert "Prior reasoning" not in texts + + +def test_parse_multimodal_media_part_base64_value_field(): + """Source with type='base64' reads data from the 'value' field per AG-UI spec.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "base64", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_data_source_value_field(): + """Source with type='data' reads data from the 'value' field per AG-UI spec.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "data", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_base64_data_field_backward_compat(): + """Source with type='base64' still supports deprecated 'data' field.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "base64", "data": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri + + +def test_parse_multimodal_media_part_value_preferred_over_data(): + """When both 'value' and 'data' are present, 'value' takes precedence.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + { + "type": "image", + "source": { + "type": "base64", + "value": "dmFsdWU=", + "data": "ZGF0YQ==", + "mimeType": "image/png", + }, + } + ) + assert result is not None + # 'value' field content should be used (base64 of "value") + assert "dmFsdWU=" in result.uri + + +def test_parse_multimodal_media_part_unknown_source_value_fallback(): + """Unknown source type falls back to 'value' field before 'data' field.""" + from agent_framework_ag_ui._message_adapters import _parse_multimodal_media_part + + result = _parse_multimodal_media_part( + {"type": "image", "source": {"type": "custom", "value": "aGVsbG8=", "mimeType": "image/png"}} + ) + assert result is not None + assert "aGVsbG8=" in result.uri diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 70af4c064a..a901b61e6e 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -1244,7 +1244,7 @@ class TestEmitTextReasoning: assert events[0].message_id == "reason_1" assert isinstance(events[1], ReasoningMessageStartEvent) assert events[1].message_id == "reason_1" - assert events[1].role == "assistant" + assert events[1].role == "reasoning" assert isinstance(events[2], ReasoningMessageContentEvent) assert events[2].message_id == "reason_1" assert events[2].delta == "The user is asking about weather, so I should call the weather tool." @@ -1642,6 +1642,37 @@ class TestReasoningInSnapshot: assert close[0].message_id == "block2" +class TestReasoningEventRole: + """Tests that reasoning events use role='reasoning' per AG-UI spec.""" + + def test_reasoning_role_without_flow(self): + """ReasoningMessageStartEvent uses role='reasoning' in non-flow mode.""" + content = Content.from_text_reasoning( + id="reason_role_1", + text="Thinking about the question.", + ) + + events = _emit_text_reasoning(content) + + msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)] + assert len(msg_starts) == 1 + assert msg_starts[0].role == "reasoning" + + def test_reasoning_role_with_flow(self): + """ReasoningMessageStartEvent uses role='reasoning' in streaming flow mode.""" + flow = FlowState() + content = Content.from_text_reasoning( + id="reason_role_2", + text="Reasoning in streaming mode.", + ) + + events = _emit_text_reasoning(content, flow) + + msg_starts = [e for e in events if isinstance(e, ReasoningMessageStartEvent)] + assert len(msg_starts) == 1 + assert msg_starts[0].role == "reasoning" + + async def test_session_id_matches_thread_id(): """Session created by run_agent_stream uses the client thread_id as session_id.""" from conftest import StubAgent diff --git a/python/uv.lock b/python/uv.lock index a1acd778bb..ecaed87fe4 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -84,14 +84,14 @@ wheels = [ [[package]] name = "ag-ui-protocol" -version = "0.1.13" +version = "0.1.17" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/04/b5/fc0b65b561d00d88811c8a7d98ee735833f81554be244340950e7b65820c/ag_ui_protocol-0.1.13.tar.gz", hash = "sha256:811d7d7dcce4783dec252918f40b717ebfa559399bf6b071c4ba47c0c1e21bcb", size = 5671, upload-time = "2026-02-19T18:40:38.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0f/5a8ce5eb5cd7adf3f733da87d7a5a8a38f24ec9e029c1300f9495ae9c7fa/ag_ui_protocol-0.1.17.tar.gz", hash = "sha256:5fae4cfced8245c8ac329b85702fd166ff226d99dc1ea8a1ae95890826aa69e5", size = 6273, upload-time = "2026-04-20T21:09:19.436Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/9f/b833c1ab1999da35ebad54841ae85d2c2764c931da9a6f52d8541b6901b2/ag_ui_protocol-0.1.13-py3-none-any.whl", hash = "sha256:1393fa894c1e8416efe184168a50689e760d05b32f4646eebb8ff423dddf8e8f", size = 8053, upload-time = "2026-02-19T18:40:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/a0/82/e5f1686b4c4e232818c75598d651b82088f584e59adef71802920d74f82f/ag_ui_protocol-0.1.17-py3-none-any.whl", hash = "sha256:6a9065590d21c7b9b8ae9bb1a3410ecf4d18cb8041a077d25574f792dfa504fe", size = 8648, upload-time = "2026-04-20T21:09:20.488Z" }, ] [[package]] @@ -183,7 +183,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "ag-ui-protocol", specifier = "==0.1.13" }, + { name = "ag-ui-protocol", specifier = ">=0.1.16,<0.2" }, { name = "agent-framework-core", editable = "packages/core" }, { name = "fastapi", specifier = ">=0.115.0,<0.133.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, From 4adfd244acb042c03e624ebd027166f18aab53d5 Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 24 Apr 2026 00:27:17 -0700 Subject: [PATCH 46/52] Python: Upgrade hosting server dependency and add more type support (#5459) * Upgrade hosting server dependency and add more type support * Comments --- .../_responses.py | 295 +++- .../packages/foundry_hosting/pyproject.toml | 8 +- .../foundry_hosting/tests/test_responses.py | 1219 ++++++++++++++++- python/uv.lock | 26 +- 4 files changed, 1492 insertions(+), 56 deletions(-) diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index 63bc730e78..cac0ac3790 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -28,13 +28,35 @@ from azure.ai.agentserver.responses import ( ) from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost from azure.ai.agentserver.responses.models import ( + ApplyPatchToolCallItemParam, + ApplyPatchToolCallOutputItemParam, + ComputerCallOutputItemParam, ComputerScreenshotContent, CreateResponse, FunctionCallOutputItemParam, FunctionShellAction, + FunctionShellCallItemParam, FunctionShellCallOutputContent, FunctionShellCallOutputExitOutcome, + FunctionShellCallOutputItemParam, + Item, + ItemCodeInterpreterToolCall, + ItemComputerToolCall, + ItemCustomToolCall, + ItemCustomToolCallOutput, + ItemFileSearchToolCall, + ItemFunctionToolCall, + ItemImageGenToolCall, + ItemLocalShellToolCall, + ItemLocalShellToolCallOutput, + ItemMcpApprovalRequest, + ItemMcpToolCall, + ItemMessage, + ItemOutputMessage, + ItemReasoningItem, + ItemWebSearchToolCall, LocalEnvironmentResource, + MCPApprovalResponse, MessageContent, MessageContentInputFileContent, MessageContentInputImageContent, @@ -174,9 +196,11 @@ class ResponsesHostServer(ResponsesAgentServerHost): context: ResponseContext, ) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]: """Handle the creation of a response for a regular (non-workflow) agent.""" - input_text = await context.get_input_text() + input_items = await context.get_input_items() + input_messages = _items_to_messages(input_items) + history = await context.get_history() - messages: list[str | Content | Message] = [*_to_messages(history), input_text] + messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages] chat_options, are_options_set = _to_chat_options(request) @@ -243,7 +267,9 @@ class ResponsesHostServer(ResponsesAgentServerHost): The sandbox may be deactivated after some period of inactivity, and only data managed by the hosting infrastructure or files will be preserved upon deactivation. """ - input_text = await context.get_input_text() + input_items = await context.get_input_items() + input_messages = _items_to_messages(input_items) + is_streaming_request = self._is_streaming_request(request) _, are_options_set = _to_chat_options(request) @@ -296,7 +322,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): if not is_streaming_request: # Run the agent in non-streaming mode - response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage) + response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage) for message in response.messages: for content in message.contents: @@ -308,7 +334,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): return # Run the agent in streaming mode - response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage) + response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage) # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. @@ -532,7 +558,260 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]: # region Input Message Conversion -def _to_messages(history: Sequence[OutputItem]) -> list[Message]: +def _items_to_messages(input_items: Sequence[Item]) -> list[Message]: + """Converts a sequence of input items to a list of Messages, one per item. + + Args: + input_items: The input items to convert. + + Returns: + A list of Messages, one per supported input item. + """ + messages: list[Message] = [] + for item in input_items: + messages.append(_item_to_message(item)) + return messages + + +def _item_to_message(item: Item) -> Message: + """Converts an Item to a Message. + + Args: + item: The Item to convert. + + Returns: + The converted Message. + + Raises: + ValueError: If the Item type is not supported. + """ + if item.type == "message": + msg = cast(ItemMessage, item) + if isinstance(msg.content, str): + return Message(role=msg.role, contents=[Content.from_text(msg.content)]) + return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content]) + + if item.type == "output_message": + output_msg = cast(ItemOutputMessage, item) + return Message( + role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content] + ) + + if item.type == "function_call": + fc = cast(ItemFunctionToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)], + ) + + if item.type == "function_call_output": + fco = cast(FunctionCallOutputItemParam, item) + output = fco.output if isinstance(fco.output, str) else str(fco.output) + return Message( + role="tool", + contents=[Content.from_function_result(fco.call_id, result=output)], + ) + + if item.type == "reasoning": + reasoning = cast(ItemReasoningItem, item) + reason_contents: list[Content] = [] + if reasoning.summary: + for summary in reasoning.summary: + reason_contents.append(Content.from_text(summary.text)) + return Message(role="assistant", contents=reason_contents) + + if item.type == "mcp_call": + mcp = cast(ItemMcpToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_mcp_server_tool_call( + mcp.id, + mcp.name, + server_name=mcp.server_label, + arguments=mcp.arguments, + ) + ], + ) + + if item.type == "mcp_approval_request": + mcp_req = cast(ItemMcpApprovalRequest, item) + mcp_call_content = Content.from_mcp_server_tool_call( + mcp_req.id, + mcp_req.name, + server_name=mcp_req.server_label, + arguments=mcp_req.arguments, + ) + return Message( + role="assistant", + contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)], + ) + + if item.type == "mcp_approval_response": + mcp_resp = cast(MCPApprovalResponse, item) + placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval") + return Message( + role="user", + contents=[ + Content.from_function_approval_response( + mcp_resp.approve, mcp_resp.approval_request_id, placeholder_content + ) + ], + ) + + if item.type == "code_interpreter_call": + ci = cast(ItemCodeInterpreterToolCall, item) + return Message( + role="assistant", + contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)], + ) + + if item.type == "image_generation_call": + ig = cast(ItemImageGenToolCall, item) + return Message( + role="assistant", + contents=[Content.from_image_generation_tool_call(image_id=ig.id)], + ) + + if item.type == "shell_call": + sc = cast(FunctionShellCallItemParam, item) + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=sc.call_id, + commands=sc.action.commands, + status=str(sc.status), + ) + ], + ) + + if item.type == "shell_call_output": + sco = cast(FunctionShellCallOutputItemParam, item) + outputs = [ + Content.from_shell_command_output( + stdout=out.stdout or "", + stderr=out.stderr or "", + exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None, + ) + for out in (sco.output or []) + ] + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=sco.call_id, + outputs=outputs, + max_output_length=sco.max_output_length, + ) + ], + ) + + if item.type == "local_shell_call": + lsc = cast(ItemLocalShellToolCall, item) + commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else [] + return Message( + role="assistant", + contents=[ + Content.from_shell_tool_call( + call_id=lsc.call_id, + commands=commands, + status=str(lsc.status), + ) + ], + ) + + if item.type == "local_shell_call_output": + lsco = cast(ItemLocalShellToolCallOutput, item) + return Message( + role="tool", + contents=[ + Content.from_shell_tool_result( + call_id=lsco.id, + outputs=[Content.from_shell_command_output(stdout=lsco.output)], + ) + ], + ) + + if item.type == "file_search_call": + fs = cast(ItemFileSearchToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + fs.id, + "file_search", + arguments=json.dumps({"queries": fs.queries}), + ) + ], + ) + + if item.type == "web_search_call": + ws = cast(ItemWebSearchToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ws.id, "web_search")], + ) + + if item.type == "computer_call": + cc = cast(ItemComputerToolCall, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + cc.call_id, + "computer_use", + arguments=str(cc.action), + ) + ], + ) + + if item.type == "computer_call_output": + cco = cast(ComputerCallOutputItemParam, item) + return Message( + role="tool", + contents=[Content.from_function_result(cco.call_id, result=str(cco.output))], + ) + + if item.type == "custom_tool_call": + ct = cast(ItemCustomToolCall, item) + return Message( + role="assistant", + contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)], + ) + + if item.type == "custom_tool_call_output": + cto = cast(ItemCustomToolCallOutput, item) + output = cto.output if isinstance(cto.output, str) else str(cto.output) + return Message( + role="tool", + contents=[Content.from_function_result(cto.call_id, result=output)], + ) + + if item.type == "apply_patch_call": + ap = cast(ApplyPatchToolCallItemParam, item) + return Message( + role="assistant", + contents=[ + Content.from_function_call( + ap.call_id, + "apply_patch", + arguments=str(ap.operation), + ) + ], + ) + + if item.type == "apply_patch_call_output": + apo = cast(ApplyPatchToolCallOutputItemParam, item) + return Message( + role="tool", + contents=[Content.from_function_result(apo.call_id, result=apo.output or "")], + ) + + raise ValueError(f"Unsupported Item type: {item.type}") + + +def _output_items_to_messages(history: Sequence[OutputItem]) -> list[Message]: """Converts a sequence of OutputItem objects to a list of Message objects. Args: @@ -543,11 +822,11 @@ def _to_messages(history: Sequence[OutputItem]) -> list[Message]: """ messages: list[Message] = [] for item in history: - messages.append(_to_message(item)) + messages.append(_output_item_to_message(item)) return messages -def _to_message(item: OutputItem) -> Message: +def _output_item_to_message(item: OutputItem) -> Message: """Converts an OutputItem to a Message. Args: diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index fcd1e8e21b..a9d0393a1d 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -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.0a260423" +version = "1.0.0a260424" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -24,9 +24,9 @@ classifiers = [ ] dependencies = [ "agent-framework-core>=1.1.1,<2", - "azure-ai-agentserver-core==2.0.0b2", - "azure-ai-agentserver-responses==1.0.0b4", - "azure-ai-agentserver-invocations==1.0.0b2", + "azure-ai-agentserver-core==2.0.0b3", + "azure-ai-agentserver-responses==1.0.0b5", + "azure-ai-agentserver-invocations==1.0.0b3", ] [tool.uv] diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index f30d033009..13538b6c9a 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -29,7 +29,10 @@ from azure.ai.agentserver.responses import InMemoryResponseProvider from typing_extensions import Any from agent_framework_foundry_hosting import ResponsesHostServer -from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage] +from agent_framework_foundry_hosting._responses import ( + _item_to_message, # pyright: ignore[reportPrivateUsage] + _output_item_to_message, # pyright: ignore[reportPrivateUsage] +) # region Helpers @@ -525,11 +528,11 @@ class TestStreaming: # endregion -# region _to_message conversion +# region _output_item_to_message conversion -class TestToMessage: - """Tests for _to_message covering all supported OutputItem types.""" +class TestOutputItemToMessage: + """Tests for _output_item_to_message covering all supported OutputItem types.""" def test_output_message(self) -> None: from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent @@ -541,7 +544,7 @@ class TestToMessage: "status": "completed", "id": "msg-1", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert len(msg.contents) == 1 assert msg.contents[0].type == "text" @@ -555,7 +558,7 @@ class TestToMessage: "role": "user", "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "user" assert len(msg.contents) == 1 assert msg.contents[0].text == "hi" @@ -571,7 +574,7 @@ class TestToMessage: "status": "completed", "id": "fc-1", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].call_id == "call_1" @@ -581,7 +584,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"}) - msg = _to_message(item) # type: ignore[arg-type] + msg = _output_item_to_message(item) # type: ignore[arg-type] assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_1" @@ -595,7 +598,7 @@ class TestToMessage: "id": "r-1", "summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert len(msg.contents) == 1 assert msg.contents[0].text == "thinking hard" @@ -604,7 +607,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import OutputItemReasoningItem item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents == [] @@ -618,7 +621,7 @@ class TestToMessage: "name": "search", "arguments": '{"q": "test"}', }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "mcp_server_tool_call" assert msg.contents[0].server_name == "my_server" @@ -634,7 +637,7 @@ class TestToMessage: "name": "dangerous_tool", "arguments": "{}", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_approval_request" @@ -647,7 +650,7 @@ class TestToMessage: "approval_request_id": "apr-1", "approve": True, }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "user" assert msg.contents[0].type == "function_approval_response" assert msg.contents[0].approved is True @@ -663,7 +666,7 @@ class TestToMessage: "code": "print('hi')", "outputs": [], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "code_interpreter_tool_call" @@ -671,7 +674,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "image_generation_tool_call" @@ -690,7 +693,7 @@ class TestToMessage: "status": "completed", "environment": FunctionShellCallEnvironment({"type": "local"}), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["ls", "-la"] @@ -717,7 +720,7 @@ class TestToMessage: ], "max_output_length": 1024, }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "shell_tool_result" assert msg.contents[0].call_id == "call_sc" @@ -732,7 +735,7 @@ class TestToMessage: "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), "status": "completed", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "shell_tool_call" assert msg.contents[0].commands == ["echo", "hello"] @@ -745,7 +748,7 @@ class TestToMessage: "id": "lsco-1", "output": "hello\n", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "shell_tool_result" @@ -758,7 +761,7 @@ class TestToMessage: "status": "completed", "queries": ["what is AI"], }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "file_search" @@ -773,7 +776,7 @@ class TestToMessage: "status": "completed", "action": WebSearchActionSearch({"type": "search", "query": "test"}), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "web_search" @@ -789,7 +792,7 @@ class TestToMessage: "pending_safety_checks": [], "status": "completed", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "computer_use" @@ -808,7 +811,7 @@ class TestToMessage: "image_url": "data:image/png;base64,abc", }), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].call_id == "call_cc" @@ -822,7 +825,7 @@ class TestToMessage: "name": "my_tool", "input": '{"key": "value"}', }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "my_tool" @@ -836,7 +839,7 @@ class TestToMessage: "call_id": "call_ct", "output": "result text", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].result == "result text" @@ -855,7 +858,7 @@ class TestToMessage: "diff": "+ new line", }), }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "function_call" assert msg.contents[0].name == "apply_patch" @@ -870,7 +873,7 @@ class TestToMessage: "status": "completed", "output": "patch applied", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "tool" assert msg.contents[0].type == "function_result" assert msg.contents[0].result == "patch applied" @@ -884,7 +887,7 @@ class TestToMessage: "consent_link": "https://example.com/consent", "server_label": "my_server", }) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "oauth_consent_request" assert msg.contents[0].consent_link == "https://example.com/consent" @@ -893,7 +896,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].type == "text" assert json.loads(msg.contents[0].text or "") == {"answer": 42} @@ -902,7 +905,7 @@ class TestToMessage: from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"}) - msg = _to_message(item) + msg = _output_item_to_message(item) assert msg.role == "assistant" assert msg.contents[0].text == "plain text" @@ -911,7 +914,1161 @@ class TestToMessage: item = OutputItem({"type": "some_unknown_type"}) with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"): - _to_message(item) + _output_item_to_message(item) + + +# endregion + + +# region _item_to_message conversion + + +class TestItemToMessage: + """Tests for _item_to_message covering all supported Item types.""" + + def test_message_with_string_content(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage + + item = ItemMessage({"type": "message", "role": "user", "content": "hello"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "user" + assert len(msg.contents) == 1 + assert msg.contents[0].type == "text" + assert msg.contents[0].text == "hello" + + def test_message_with_input_text_content(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent + + item = ItemMessage({ + "type": "message", + "role": "user", + "content": [MessageContentInputTextContent({"type": "input_text", "text": "hi there"})], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "user" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "hi there" + + def test_message_with_multiple_contents(self) -> None: + from azure.ai.agentserver.responses.models import ItemMessage, MessageContentInputTextContent + + item = ItemMessage({ + "type": "message", + "role": "user", + "content": [ + MessageContentInputTextContent({"type": "input_text", "text": "first"}), + MessageContentInputTextContent({"type": "input_text", "text": "second"}), + ], + }) + msg = _item_to_message(item) + assert msg is not None + assert len(msg.contents) == 2 + assert msg.contents[0].text == "first" + assert msg.contents[1].text == "second" + + def test_output_message(self) -> None: + from azure.ai.agentserver.responses.models import ItemOutputMessage, OutputMessageContentOutputTextContent + + item = ItemOutputMessage({ + "type": "output_message", + "role": "assistant", + "content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "response"})], + "status": "completed", + "id": "msg-1", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].type == "text" + assert msg.contents[0].text == "response" + + def test_function_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemFunctionToolCall + + item = ItemFunctionToolCall({ + "type": "function_call", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "NYC"}', + "status": "completed", + "id": "fc-1", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].name == "get_weather" + assert msg.contents[0].arguments == '{"city": "NYC"}' + + def test_function_call_output(self) -> None: + from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam + + item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"}) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_1" + assert msg.contents[0].result == "sunny" + + def test_function_call_output_non_string(self) -> None: + from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam + + item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_2", "output": 42}) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].result == "42" + + def test_reasoning_with_summary(self) -> None: + from azure.ai.agentserver.responses.models import ItemReasoningItem, SummaryTextContent + + item = ItemReasoningItem({ + "type": "reasoning", + "id": "r-1", + "summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert len(msg.contents) == 1 + assert msg.contents[0].text == "thinking hard" + + def test_reasoning_no_summary(self) -> None: + from azure.ai.agentserver.responses.models import ItemReasoningItem + + item = ItemReasoningItem({"type": "reasoning", "id": "r-2"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents == [] + + def test_mcp_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemMcpToolCall + + item = ItemMcpToolCall({ + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"q": "test"}', + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "mcp_server_tool_call" + assert msg.contents[0].server_name == "my_server" + assert msg.contents[0].tool_name == "search" + + def test_mcp_approval_request(self) -> None: + from azure.ai.agentserver.responses.models import ItemMcpApprovalRequest + + item = ItemMcpApprovalRequest({ + "type": "mcp_approval_request", + "id": "apr-1", + "server_label": "srv", + "name": "dangerous_tool", + "arguments": "{}", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_approval_request" + + def test_mcp_approval_response(self) -> None: + from azure.ai.agentserver.responses.models import MCPApprovalResponse + + item = MCPApprovalResponse({ + "type": "mcp_approval_response", + "approval_request_id": "apr-1", + "approve": True, + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "user" + assert msg.contents[0].type == "function_approval_response" + assert msg.contents[0].approved is True + + def test_code_interpreter_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemCodeInterpreterToolCall + + item = ItemCodeInterpreterToolCall({ + "type": "code_interpreter_call", + "id": "ci-1", + "status": "completed", + "container_id": "c-1", + "code": "print('hi')", + "outputs": [], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "code_interpreter_tool_call" + + def test_image_generation_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemImageGenToolCall + + item = ItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"}) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "image_generation_tool_call" + + def test_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import FunctionShellAction, FunctionShellCallItemParam + + item = FunctionShellCallItemParam({ + "type": "shell_call", + "call_id": "call_sc", + "action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}), + "status": "in_progress", + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["ls", "-la"] + assert msg.contents[0].call_id == "call_sc" + + def test_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ( + FunctionShellCallOutputContent, + FunctionShellCallOutputExitOutcome, + FunctionShellCallOutputItemParam, + ) + + item = FunctionShellCallOutputItemParam({ + "type": "shell_call_output", + "call_id": "call_sc", + "output": [ + FunctionShellCallOutputContent({ + "stdout": "file.txt", + "stderr": "", + "outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}), + }) + ], + "max_output_length": 1024, + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + assert msg.contents[0].call_id == "call_sc" + + def test_local_shell_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemLocalShellToolCall, LocalShellExecAction + + item = ItemLocalShellToolCall({ + "type": "local_shell_call", + "id": "lsc-1", + "call_id": "call_lsc", + "action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}), + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "shell_tool_call" + assert msg.contents[0].commands == ["echo", "hello"] + + def test_local_shell_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ItemLocalShellToolCallOutput + + item = ItemLocalShellToolCallOutput({ + "type": "local_shell_call_output", + "id": "lsco-1", + "output": "hello\n", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "shell_tool_result" + + def test_file_search_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemFileSearchToolCall + + item = ItemFileSearchToolCall({ + "type": "file_search_call", + "id": "fs-1", + "status": "completed", + "queries": ["what is AI"], + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "file_search" + assert '"what is AI"' in (msg.contents[0].arguments or "") + + def test_web_search_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemWebSearchToolCall + + item = ItemWebSearchToolCall({ + "type": "web_search_call", + "id": "ws-1", + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "web_search" + + def test_computer_call(self) -> None: + from azure.ai.agentserver.responses.models import ComputerAction, ItemComputerToolCall + + item = ItemComputerToolCall({ + "type": "computer_call", + "id": "cc-1", + "call_id": "call_cc", + "action": ComputerAction({"type": "click"}), + "pending_safety_checks": [], + "status": "completed", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "computer_use" + + def test_computer_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ComputerCallOutputItemParam, ComputerScreenshotImage + + item = ComputerCallOutputItemParam({ + "type": "computer_call_output", + "call_id": "call_cc", + "output": ComputerScreenshotImage({ + "type": "computer_screenshot", + "image_url": "data:image/png;base64,abc", + }), + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].call_id == "call_cc" + + def test_custom_tool_call(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCall + + item = ItemCustomToolCall({ + "type": "custom_tool_call", + "call_id": "call_ct", + "name": "my_tool", + "input": '{"key": "value"}', + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "my_tool" + assert msg.contents[0].arguments == '{"key": "value"}' + + def test_custom_tool_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput + + item = ItemCustomToolCallOutput({ + "type": "custom_tool_call_output", + "call_id": "call_ct", + "output": "result text", + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "result text" + + def test_custom_tool_call_output_non_string(self) -> None: + from azure.ai.agentserver.responses.models import ItemCustomToolCallOutput + + item = ItemCustomToolCallOutput({ + "type": "custom_tool_call_output", + "call_id": "call_ct2", + "output": 123, + }) + msg = _item_to_message(item) + assert msg is not None + assert msg.contents[0].result == "123" + + def test_apply_patch_call(self) -> None: + from azure.ai.agentserver.responses.models import ApplyPatchToolCallItemParam, ApplyPatchUpdateFileOperation + + item = ApplyPatchToolCallItemParam({ + "type": "apply_patch_call", + "call_id": "call_ap", + "operation": ApplyPatchUpdateFileOperation({ + "type": "update_file", + "path": "file.py", + "diff": "+ new line", + }), + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "assistant" + assert msg.contents[0].type == "function_call" + assert msg.contents[0].name == "apply_patch" + + def test_apply_patch_call_output(self) -> None: + from azure.ai.agentserver.responses.models import ApplyPatchToolCallOutputItemParam + + item = ApplyPatchToolCallOutputItemParam({ + "type": "apply_patch_call_output", + "call_id": "call_ap", + "output": "patch applied", + }) + msg = _item_to_message(item) # type: ignore[arg-type] + assert msg is not None + assert msg.role == "tool" + assert msg.contents[0].type == "function_result" + assert msg.contents[0].result == "patch applied" + + def test_unsupported_type_raises(self) -> None: + from azure.ai.agentserver.responses.models import Item + + item = Item({"type": "some_unknown_type"}) + with pytest.raises(ValueError, match="Unsupported Item type: some_unknown_type"): + _item_to_message(item) + + +# endregion + + +# region Multi-turn with mixed content + + +async def _post_json( + server: ResponsesHostServer, + payload: dict[str, Any], +) -> httpx.Response: + """Send a POST /responses request with a raw JSON payload.""" + transport = httpx.ASGITransport(app=server) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/responses", json=payload) + + +def _make_multi_response_agent( + responses: list[AgentResponse], + stream_updates_list: list[list[AgentResponseUpdate]] | None = None, +) -> MagicMock: + """Create a mock agent that returns different responses on successive calls.""" + agent = MagicMock(spec=RawAgent) + agent.id = "test-agent" + agent.name = "Test Agent" + agent.description = "A mock agent for testing" + agent.context_providers = [] + + call_index = [0] + + async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse: + idx = call_index[0] + call_index[0] += 1 + return responses[idx] + + async def _stream_gen(updates: list[AgentResponseUpdate]) -> AsyncIterator[AgentResponseUpdate]: + for update in updates: + yield update + + def run_dispatch(*args: Any, **kwargs: Any) -> Any: + idx = call_index[0] + call_index[0] += 1 + if kwargs.get("stream") and stream_updates_list is not None: + return ResponseStream(_stream_gen(stream_updates_list[idx])) # type: ignore + if not kwargs.get("stream"): + # Need to return a coroutine for non-streaming + async def _ret() -> AgentResponse: + return responses[idx] + + return _ret() + raise NotImplementedError("Streaming not configured for this call index") + + if stream_updates_list is not None: + agent.run = MagicMock(side_effect=run_dispatch) + else: + agent.run = AsyncMock(side_effect=run_non_streaming) + + return agent + + +class TestMultiTurnMixedContent: + """End-to-end multi-turn tests with mixed text and non-text content types.""" + + async def test_text_and_image_input_single_turn(self) -> None: + """Agent receives a message with text and image content via URL.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("I see a cat!")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this animal"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + # Verify agent received text + image + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert messages[0].role == "user" + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Describe this animal" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/cat.jpg" + + async def test_text_and_file_input_single_turn(self) -> None: + """Agent receives a message with text and file content via URL.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("File received")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Summarize this document"}, + {"type": "input_file", "file_url": "https://example.com/doc.pdf", "filename": "doc.pdf"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Summarize this document" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/doc.pdf" + + async def test_mixed_text_and_image_input(self) -> None: + """Agent receives a single message with both text and image content.""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Got it!")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What's in this image?"}, + {"type": "input_image", "image_url": "https://example.com/photo.jpg"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "What's in this image?" + assert messages[0].contents[1].type == "uri" + assert messages[0].contents[1].uri == "https://example.com/photo.jpg" + + async def test_function_call_items_in_input(self) -> None: + """Input contains function_call and function_call_output items.""" + agent = _make_agent( + response=AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("Weather is sunny!")])] + ) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + {"type": "message", "role": "user", "content": "What's the weather?"}, + { + "type": "function_call", + "id": "fc-1", + "call_id": "call_1", + "name": "get_weather", + "arguments": '{"city": "NYC"}', + "status": "completed", + }, + {"type": "function_call_output", "call_id": "call_1", "output": "sunny, 72F"}, + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 3 + assert messages[0].role == "user" + assert messages[0].contents[0].type == "text" + assert messages[1].role == "assistant" + assert messages[1].contents[0].type == "function_call" + assert messages[1].contents[0].name == "get_weather" + assert messages[2].role == "tool" + assert messages[2].contents[0].type == "function_result" + assert messages[2].contents[0].result == "sunny, 72F" + + async def test_multi_turn_text_then_text_with_image(self) -> None: + """First turn sends text, second turn sends text + image with previous_response_id.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Send me an image")])]), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Nice cat!")])]), + ]) + server = _make_server(agent) + + # Turn 1: simple text + resp1 = await _post(server, input_text="Hello", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Turn 2: text + image input referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Here is my cat photo"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": False, + "previous_response_id": response_id, + }, + ) + + assert resp2.status_code == 200 + body2 = resp2.json() + assert body2["status"] == "completed" + + # Verify second call receives history from turn 1 + text+image input + second_call_messages = agent.run.call_args_list[1].args[0] + # History: output message from turn 1 ("Send me an image") + # Input: message with text + image + assert len(second_call_messages) >= 2 + # Last message should be the text+image input + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Here is my cat photo" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/cat.jpg" + # History should include the assistant response from turn 1 + history_msgs = second_call_messages[:-1] + assistant_texts = [ + c.text for m in history_msgs if m.role == "assistant" for c in m.contents if c.type == "text" + ] + assert "Send me an image" in assistant_texts + + async def test_multi_turn_function_call_in_history(self) -> None: + """Turn 1 produces function call + result, turn 2 sees them in history.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "search", arguments='{"q": "cats"}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="found 10 cats")]), + Message(role="assistant", contents=[Content.from_text("I found 10 cats!")]), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Here are more details")])]), + ]) + server = _make_server(agent) + + # Turn 1 + resp1 = await _post(server, input_text="Search for cats", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Verify turn 1 output has function_call, function_call_output, and message + types1 = [item["type"] for item in resp1.json()["output"]] + assert "function_call" in types1 + assert "function_call_output" in types1 + assert "message" in types1 + + # Turn 2 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Tell me more", + "stream": False, + "previous_response_id": response_id, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history including function call/result + second_call_messages = agent.run.call_args_list[1].args[0] + roles = [m.role for m in second_call_messages] + assert "assistant" in roles + assert "tool" in roles + # The function call should be in the history + fc_contents = [ + c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" + ] + assert len(fc_contents) >= 1 + assert fc_contents[0].name == "search" + + async def test_multi_turn_reasoning_in_history(self) -> None: + """Turn 1 produces reasoning + text, turn 2 sees them in history.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_text_reasoning(text="Let me think about this..."), + Content.from_text("The answer is 42"), + ], + ), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Indeed, it is 42")])]), + ]) + server = _make_server(agent) + + # Turn 1 + resp1 = await _post(server, input_text="What is the answer?", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + types1 = [item["type"] for item in resp1.json()["output"]] + assert "reasoning" in types1 + assert "message" in types1 + + # Turn 2 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Are you sure?", + "stream": False, + "previous_response_id": response_id, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify history includes the reasoning and text from turn 1 + second_call_messages = agent.run.call_args_list[1].args[0] + assert len(second_call_messages) >= 2 # history + new input + + async def test_multi_turn_with_mixed_content_and_streaming(self) -> None: + """Turn 1 non-streaming, turn 2 streaming with image input.""" + turn2_updates = [ + AgentResponseUpdate(contents=[Content.from_text("I see ")], role="assistant"), + AgentResponseUpdate(contents=[Content.from_text("a cat!")], role="assistant"), + ] + + agent = _make_multi_response_agent( + responses=[ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Send me an image")])]), + AgentResponse(messages=[]), # placeholder, not used for streaming + ], + stream_updates_list=[ + [], # placeholder for turn 1 (non-streaming) + turn2_updates, + ], + ) + server = _make_server(agent) + + # Turn 1: non-streaming text + resp1 = await _post(server, input_text="Hello", stream=False) + assert resp1.status_code == 200 + response_id = resp1.json()["id"] + + # Turn 2: streaming with image input + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Describe this:"}, + {"type": "input_image", "image_url": "https://example.com/cat.jpg"}, + ], + } + ], + "stream": True, + "previous_response_id": response_id, + }, + ) + + assert resp2.status_code == 200 + assert "text/event-stream" in resp2.headers["content-type"] + + events = _parse_sse_events(resp2.text) + types = _sse_event_types(events) + assert types[0] == "response.created" + assert types[-1] == "response.completed" + assert "response.output_text.delta" in types + + # Verify accumulated text + text_done = [e for e in events if e["event"] == "response.output_text.done"] + assert len(text_done) == 1 + assert text_done[0]["data"]["text"] == "I see a cat!" + + async def test_text_with_mcp_call_items(self) -> None: + """Input contains text message + mcp_call item and the agent processes it.""" + agent = _make_agent( + response=AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("MCP result received")])] + ) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + {"type": "message", "role": "user", "content": "Search using MCP"}, + { + "type": "mcp_call", + "id": "mcp-1", + "server_label": "my_server", + "name": "search", + "arguments": '{"query": "test"}', + }, + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 2 + assert messages[0].role == "user" + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Search using MCP" + assert messages[1].role == "assistant" + assert messages[1].contents[0].type == "mcp_server_tool_call" + assert messages[1].contents[0].server_name == "my_server" + assert messages[1].contents[0].tool_name == "search" + + async def test_three_turn_conversation_with_mixed_content(self) -> None: + """Three-turn conversation: text → function call → image input.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello! How can I help?")])]), + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "analyze", arguments='{"mode": "deep"}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="analysis complete")]), + Message(role="assistant", contents=[Content.from_text("Analysis done!")]), + ] + ), + AgentResponse( + messages=[Message(role="assistant", contents=[Content.from_text("The image shows a chart")])] + ), + ]) + server = _make_server(agent) + + # Turn 1: text + resp1 = await _post(server, input_text="Hi", stream=False) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + # Turn 2: text, referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": "Analyze something", + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + id2 = resp2.json()["id"] + + # Turn 3: image input, referencing turn 2 + resp3 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What about this image?"}, + {"type": "input_image", "image_url": "https://example.com/chart.png"}, + ], + } + ], + "stream": False, + "previous_response_id": id2, + }, + ) + + assert resp3.status_code == 200 + assert resp3.json()["status"] == "completed" + + # Verify turn 3 received full history from turns 1+2 plus new image input + third_call_messages = agent.run.call_args_list[2].args[0] + # Should have: history from turn 1 (assistant text) + history from turn 2 + # (function_call, function_call_output, text) + new input (text + image) + assert len(third_call_messages) >= 5 + + # Last message should contain the image + last_msg = third_call_messages[-1] + assert last_msg.role == "user" + image_contents = [c for c in last_msg.contents if c.type == "uri"] + assert len(image_contents) == 1 + assert image_contents[0].uri == "https://example.com/chart.png" + + # History should include function call from turn 2 + fc_contents = [ + c + for m in third_call_messages[:-1] + if m.role == "assistant" + for c in m.contents + if c.type == "function_call" + ] + assert any(c.name == "analyze" for c in fc_contents) + + async def test_input_with_hosted_file_image(self) -> None: + """Input contains an image referenced by file_id (hosted file).""" + agent = _make_agent( + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Image analyzed")])]) + ) + server = _make_server(agent) + + resp = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Analyze this image"}, + {"type": "input_image", "file_id": "file-abc123"}, + ], + } + ], + "stream": False, + }, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "completed" + + messages = agent.run.call_args.args[0] + assert len(messages) == 1 + assert len(messages[0].contents) == 2 + assert messages[0].contents[0].type == "text" + assert messages[0].contents[0].text == "Analyze this image" + assert messages[0].contents[1].type == "hosted_file" + assert messages[0].contents[1].file_id == "file-abc123" + + async def test_multi_turn_text_and_image_then_text_and_file(self) -> None: + """Turn 1 sends text+image, turn 2 sends text+file, both in history.""" + agent = _make_multi_response_agent([ + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("I see a landscape")])]), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Document summarized")])]), + ]) + server = _make_server(agent) + + # Turn 1: text + image + resp1 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "What is in this photo?"}, + {"type": "input_image", "image_url": "https://example.com/landscape.jpg"}, + ], + } + ], + "stream": False, + }, + ) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + # Turn 2: text + file, referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Now summarize this report"}, + { + "type": "input_file", + "file_url": "https://example.com/report.pdf", + "filename": "report.pdf", + }, + ], + } + ], + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history from turn 1 + new text+file input + second_call_messages = agent.run.call_args_list[1].args[0] + assert len(second_call_messages) >= 2 + + # History should include the assistant response from turn 1 + assistant_texts = [ + c.text for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "text" + ] + assert "I see a landscape" in assistant_texts + + # Last message should be text + file + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Now summarize this report" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/report.pdf" + + async def test_multi_turn_function_call_then_text_and_image(self) -> None: + """Turn 1: text + function call + result, turn 2: text + image.""" + agent = _make_multi_response_agent([ + AgentResponse( + messages=[ + Message( + role="assistant", + contents=[Content.from_function_call("call_1", "get_info", arguments='{"id": 1}')], + ), + Message(role="tool", contents=[Content.from_function_result("call_1", result="info data")]), + Message(role="assistant", contents=[Content.from_text("Here is the info")]), + ] + ), + AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Image matches the data")])]), + ]) + server = _make_server(agent) + + # Turn 1: text triggers function call + resp1 = await _post(server, input_text="Get info for item 1", stream=False) + assert resp1.status_code == 200 + id1 = resp1.json()["id"] + + types1 = [item["type"] for item in resp1.json()["output"]] + assert "function_call" in types1 + assert "function_call_output" in types1 + assert "message" in types1 + + # Turn 2: text + image referencing turn 1 + resp2 = await _post_json( + server, + { + "model": "test-model", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "Does this image match?"}, + {"type": "input_image", "image_url": "https://example.com/item1.jpg"}, + ], + } + ], + "stream": False, + "previous_response_id": id1, + }, + ) + assert resp2.status_code == 200 + assert resp2.json()["status"] == "completed" + + # Verify turn 2 received history with function call + new text+image + second_call_messages = agent.run.call_args_list[1].args[0] + # History should contain function_call and function_result from turn 1 + fc_contents = [ + c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" + ] + assert any(c.name == "get_info" for c in fc_contents) + tool_contents = [ + c for m in second_call_messages if m.role == "tool" for c in m.contents if c.type == "function_result" + ] + assert any(c.result == "info data" for c in tool_contents) + + # Last message should be text + image + last_msg = second_call_messages[-1] + assert last_msg.role == "user" + assert len(last_msg.contents) == 2 + assert last_msg.contents[0].type == "text" + assert last_msg.contents[0].text == "Does this image match?" + assert last_msg.contents[1].type == "uri" + assert last_msg.contents[1].uri == "https://example.com/item1.jpg" # endregion diff --git a/python/uv.lock b/python/uv.lock index ecaed87fe4..6f1b6b0bbe 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -504,7 +504,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-hosting" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/foundry_hosting" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -516,9 +516,9 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-framework-core", editable = "packages/core" }, - { name = "azure-ai-agentserver-core", specifier = "==2.0.0b2" }, - { name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b2" }, - { name = "azure-ai-agentserver-responses", specifier = "==1.0.0b4" }, + { name = "azure-ai-agentserver-core", specifier = "==2.0.0b3" }, + { name = "azure-ai-agentserver-invocations", specifier = "==1.0.0b3" }, + { name = "azure-ai-agentserver-responses", specifier = "==1.0.0b5" }, ] [[package]] @@ -1068,7 +1068,7 @@ wheels = [ [[package]] name = "azure-ai-agentserver-core" -version = "2.0.0b2" +version = "2.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-monitor-opentelemetry-exporter", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1078,26 +1078,26 @@ dependencies = [ { name = "opentelemetry-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "starlette", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a0/25/25865cfa76cbc20c18c4e9ed337456fd7374c01e930dd151463b4c183ac0/azure_ai_agentserver_core-2.0.0b2.tar.gz", hash = "sha256:cc6c90fdc4c2b2ce594f0e85288fda84910c04939d1427a64a485b2d48d6d684", size = 41605, upload-time = "2026-04-19T08:58:09.27Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/29/1a9606d5252b02d77070a1b633dd0c26fe65a0f4a0fb0cfdaa751e2ed458/azure_ai_agentserver_core-2.0.0b3.tar.gz", hash = "sha256:e295b19a65d53c513929f52f0862bbb815cc9e9fc29d2a2825452f3136260123", size = 42573, upload-time = "2026-04-23T04:13:16.717Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/35/cf8a034f86d653fa902edb5ffa0a86005ea941f2840d2fa27302484856c1/azure_ai_agentserver_core-2.0.0b2-py3-none-any.whl", hash = "sha256:931e7a2d82275a01d7eb5ef08a70dba230938e3646be64c03d82749dd7be8afc", size = 27494, upload-time = "2026-04-19T08:58:10.588Z" }, + { url = "https://files.pythonhosted.org/packages/7f/9b/1fc87c05b55821f33c46c5e8a3b97a573aa2fc4bff387e75cca1a87800b4/azure_ai_agentserver_core-2.0.0b3-py3-none-any.whl", hash = "sha256:5ef921eb9fd9c0f15682fe930320fae50dccfa915d7518f9a16d99014bbcb3cb", size = 29127, upload-time = "2026-04-23T04:13:17.976Z" }, ] [[package]] name = "azure-ai-agentserver-invocations" -version = "1.0.0b2" +version = "1.0.0b3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-ai-agentserver-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/ef/11a161fa400f28390e9885854c434417fbd204ae006ca02b3a45ab285069/azure_ai_agentserver_invocations-1.0.0b2.tar.gz", hash = "sha256:cf352fd11b0057a2af28b1a921c84fb11f2fcbb9b4185cae9d93f2a45980227b", size = 30242, upload-time = "2026-04-19T09:43:31.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/95/ebab2b06777352b33dd4c407fa5624765b7443d3b4b5fb6cb1f51660643b/azure_ai_agentserver_invocations-1.0.0b3.tar.gz", hash = "sha256:1eaad3ae8dc6a28038b9a16c7b5f853fda33202c1ea57559992a6c6fe71952a4", size = 31002, upload-time = "2026-04-23T04:30:29.449Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/f4/057206e0fca266b30ea68a531fa425078fd883500e779d5552858fe33d5b/azure_ai_agentserver_invocations-1.0.0b2-py3-none-any.whl", hash = "sha256:e799a9e6e54a10499296ee4f61720377fb31f540204832b654bac6f20e801597", size = 11432, upload-time = "2026-04-19T09:43:32.744Z" }, + { url = "https://files.pythonhosted.org/packages/5e/43/a421671296ae33b62af3a034869fa82ff1979e5f455a29924d30ae1b8307/azure_ai_agentserver_invocations-1.0.0b3-py3-none-any.whl", hash = "sha256:771a15a3509e049b56f71c43c87a3fdeecd12addddcae0f80339990adc41e678", size = 11433, upload-time = "2026-04-23T04:30:30.412Z" }, ] [[package]] name = "azure-ai-agentserver-responses" -version = "1.0.0b4" +version = "1.0.0b5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1105,9 +1105,9 @@ dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "isodate", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/01/614dafa9366a5bdfe50ec112b15faa57e32a96866796bc2812ba329f4fec/azure_ai_agentserver_responses-1.0.0b4.tar.gz", hash = "sha256:2fa69db26ff52d8d2cd667a1461675e5124aabf8f268b842402e36f50d6c7176", size = 397007, upload-time = "2026-04-20T07:33:18.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/3ecb7fe704ff8764199bfbe4cc1e584a520a9affe042470d9d50b6e1e73a/azure_ai_agentserver_responses-1.0.0b5.tar.gz", hash = "sha256:0b627b810359c792ea7b6fa6782abaf6df32d9bc9e5a569ad722afcffd0ce8d9", size = 410908, upload-time = "2026-04-23T04:31:15.414Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/bd/c56df7c9257f10014ae1cd161ac08784bd9fe682233ab1a987c98b5b78c0/azure_ai_agentserver_responses-1.0.0b4-py3-none-any.whl", hash = "sha256:7684c6bef57bdcd1941cce2d6b5e2ea07edd7ce9f90e84f171804cc728b60fcc", size = 263375, upload-time = "2026-04-20T07:33:19.956Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/1e5c0d7ce95ca8b022e69e4ca6b23e413fc2d57f0191429c4633e02213d2/azure_ai_agentserver_responses-1.0.0b5-py3-none-any.whl", hash = "sha256:4c2a6ab56e71eeb330aa52b7cb2cc71b8ec6b5bbe0e7dc84310f2c7fbda393a3", size = 268362, upload-time = "2026-04-23T04:31:17.014Z" }, ] [[package]] From b00465d7bef4858f35c0cef15de1a30298a2dde4 Mon Sep 17 00:00:00 2001 From: Shubham Kumar <41825906+Shubham-Kumar-2000@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:05:40 +0530 Subject: [PATCH 47/52] Python: feat: Add Agent Framework to A2A bridge support (#2403) * feat: Add Agent Framework to A2A bridge support - Implement A2A event adapter for converting agent messages to A2A protocol - Add A2A execution context for managing agent execution state - Implement A2A executor for running agents in A2A environment - Add comprehensive unit tests for event adapter, execution context, and executor - Update agent framework core A2A module exports and type stubs - Integrate thread management utilities for async execution - Add getting started sample for A2A agent framework integration - Update dependencies in uv.lock This integration enables agent framework agents to communicate and execute within the A2A (Agent to Agent) infrastructure. * fix: Update references from agent_thread_storage to _agent_thread_storage in A2A executor tests * Refactor A2A agent framework and improve code structure - Reordered imports in various files for consistency and clarity. - Updated `__all__` definitions to maintain a consistent order across modules. - Simplified method signatures by removing unnecessary line breaks. - Enhanced readability by adjusting formatting in several sections. - Removed redundant comments and example scenarios in the execution context. - Improved handling of agent messages in the event adapter. - Added type hints for better clarity and type checking. - Cleaned up test cases for better organization and readability. * fix: Lint fix new line added * test: Add unit tests for AgentThreadStorage and InMemoryAgentThreadStorage * refactor: Update type hints to use new syntax for Union and List * fix: Validate RequestContext for context_id and message before execution * Refactor tests and remove A2aExecutionContext references - Deleted the test file for A2aExecutionContext as it is no longer needed. - Updated A2aExecutor tests to remove dependencies on A2aExecutionContext and adjusted method calls accordingly. - Modified event adapter tests to use ChatMessage instead of AgentRunResponseUpdate. - Removed A2aExecutionContext from imports in agent_framework.a2a module and updated type hints accordingly. * Refactor A2AExecutor tests and remove event adapter - Updated test cases to use A2AExecutor instead of A2aExecutor for consistency. - Removed mock_event_adapter fixture and related tests as A2aEventAdapter is deprecated. - Consolidated event handling tests into TestA2AExecutorEventAdapter. - Adjusted imports in various files to reflect the removal of deprecated components. - Ensured all references to A2aExecutor are updated to A2AExecutor across the codebase. * refactor: Remove AgentThreadStorage and InMemoryAgentThreadStorage classes from threads and tests * feat: A2AExecutor to have its own override able save and get threads methods for persistent storage. * fix: linter bugs * removed unnecessary changes form core package * new line added * Refactor A2AExecutor tests and update imports - Consolidated mock agent fixtures in test_a2a_executor.py to simplify agent mocking. - Removed redundant tests related to thread storage and agent types, focusing on A2AExecutor's core functionality. - Updated test assertions to reflect changes in message handling with new Message and Content classes. - Enhanced integration tests to ensure compatibility with the new agent framework structure. - Added A2AExecutor to the module exports in __init__.py and __init__.pyi for better accessibility. * Update A2A documentation: enhance usage examples for A2AAgent and A2AExecutor * Updated uv lock * Fix metadata assertion in TestA2AExecutorHandleEvents and reorder load_dotenv call in agent_framework_to_a2a.py * Update agent card configuration: add default input and output modes, and fix agent creation method * Fix assertion for metadata in TestA2AExecutorHandleEvents * Fix formatting issues in TestA2AExecutorExecute and TestA2AExecutorIntegration * Enhance A2AExecutor documentation with examples and clarify agent execution process * Revert uv lock to main * Refactor A2AExecutor: Improve formatting and streamline constructor parameters * Apply suggestions from code review Co-authored-by: Eduard van Valkenburg * Refactor A2AExecutor to use SupportsAgentRun and enhance logging; update agent framework sample for flight and hotel booking capabilities * Enhance A2AExecutor with streaming support and custom run arguments; update tests for initialization and execution scenarios * Enhance A2AExecutor event handling with streamed artifact tracking; update tests for new behavior * Refactor A2AExecutor to enforce type hints for stream and run_kwargs attributes * Refactor A2AExecutor and tests: replace AsyncMock with MagicMock for response stream handling; clean up imports in agent_framework_to_a2a.py * refactor: streamline imports and improve code readability across multiple files * feat: enhance A2AExecutor cancel method with context validation and fixed review comments * feat: implement get_uri_data utility function for extracting base64 data from data URIs and update references * fix: update import path for get_uri_data utility function in A2AExecutor and A2AAgent * fix: correct error message handling in A2AExecutor and update test assertions --------- Co-authored-by: Eduard van Valkenburg --- python/packages/a2a/AGENTS.md | 36 +- python/packages/a2a/README.md | 38 + .../a2a/agent_framework_a2a/__init__.py | 2 + .../a2a/agent_framework_a2a/_a2a_executor.py | 275 ++++++ .../a2a/agent_framework_a2a/_agent.py | 13 +- .../a2a/agent_framework_a2a/_utils.py | 24 + python/packages/a2a/tests/test_a2a_agent.py | 10 +- .../packages/a2a/tests/test_a2a_executor.py | 910 ++++++++++++++++++ python/packages/a2a/tests/test_utils.py | 41 + .../core/agent_framework/a2a/__init__.py | 3 +- .../core/agent_framework/a2a/__init__.pyi | 8 +- python/samples/04-hosting/a2a/README.md | 4 + .../04-hosting/a2a/agent_framework_to_a2a.py | 70 ++ 13 files changed, 1407 insertions(+), 27 deletions(-) create mode 100644 python/packages/a2a/agent_framework_a2a/_a2a_executor.py create mode 100644 python/packages/a2a/agent_framework_a2a/_utils.py create mode 100644 python/packages/a2a/tests/test_a2a_executor.py create mode 100644 python/packages/a2a/tests/test_utils.py create mode 100644 python/samples/04-hosting/a2a/agent_framework_to_a2a.py diff --git a/python/packages/a2a/AGENTS.md b/python/packages/a2a/AGENTS.md index af6e4a492b..1474c59ef5 100644 --- a/python/packages/a2a/AGENTS.md +++ b/python/packages/a2a/AGENTS.md @@ -4,20 +4,48 @@ Agent-to-Agent (A2A) protocol support for inter-agent communication. ## Main Classes -- **`A2AAgent`** - Agent wrapper that exposes an agent via the A2A protocol +- **`A2AAgent`** - Client to connect to remote A2A-compliant agents. +- **`A2AExecutor`** - Bridge to expose Agent Framework agents via the A2A protocol. ## Usage +### A2AAgent (Client) + ```python from agent_framework.a2a import A2AAgent -a2a_agent = A2AAgent(agent=my_agent) +# Connect to a remote A2A agent +a2a_agent = A2AAgent(url="http://remote-agent/a2a") +response = await a2a_agent.run("Hello!") +``` + +### A2AExecutor (Server/Bridge) + +```python +from agent_framework.a2a import A2AExecutor +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +# Create an A2A executor for your agent +executor = A2AExecutor(agent=my_agent) + +# Set up the request handler and server application +request_handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), +) + +app = A2AStarletteApplication( + agent_card=my_agent_card, + http_handler=request_handler, +).build() ``` ## Import Path ```python -from agent_framework.a2a import A2AAgent +from agent_framework.a2a import A2AAgent, A2AExecutor # or directly: -from agent_framework_a2a import A2AAgent +from agent_framework_a2a import A2AAgent, A2AExecutor ``` diff --git a/python/packages/a2a/README.md b/python/packages/a2a/README.md index 5ae15e3647..4bdfa9221e 100644 --- a/python/packages/a2a/README.md +++ b/python/packages/a2a/README.md @@ -10,11 +10,49 @@ pip install agent-framework-a2a --pre The A2A agent integration enables communication with remote A2A-compliant agents using the standardized A2A protocol. This allows your Agent Framework applications to connect to agents running on different platforms, languages, or services. +### A2AAgent (Client) + +The `A2AAgent` class is a client that wraps an A2A Client to connect the Agent Framework with external A2A-compliant agents. + +```python +from agent_framework.a2a import A2AAgent + +# Connect to a remote A2A agent +a2a_agent = A2AAgent(url="http://remote-agent/a2a") +response = await a2a_agent.run("Hello!") +``` + +### A2AExecutor (Hosting) + +The `A2AExecutor` class bridges local AI agents built with the `agent_framework` library to the A2A protocol, allowing them to be hosted and accessed by other A2A-compliant clients. + +```python +from agent_framework.a2a import A2AExecutor +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore + +# Create an A2A executor for your agent +executor = A2AExecutor(agent=my_agent) + +# Set up the request handler and server application +request_handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), +) + +app = A2AStarletteApplication( + agent_card=my_agent_card, + http_handler=request_handler, +).build() +``` + ### Basic Usage Example See the [A2A agent examples](../../samples/04-hosting/a2a/) which demonstrate: - Connecting to remote A2A agents +- Hosting local agents via A2A protocol - Sending messages and receiving responses - Handling different content types (text, files, data) - Streaming responses and real-time interaction diff --git a/python/packages/a2a/agent_framework_a2a/__init__.py b/python/packages/a2a/agent_framework_a2a/__init__.py index 4b4d54ecc3..c5338965c2 100644 --- a/python/packages/a2a/agent_framework_a2a/__init__.py +++ b/python/packages/a2a/agent_framework_a2a/__init__.py @@ -2,6 +2,7 @@ import importlib.metadata +from ._a2a_executor import A2AExecutor from ._agent import A2AAgent, A2AContinuationToken try: @@ -12,5 +13,6 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "A2AAgent", "A2AContinuationToken", + "A2AExecutor", "__version__", ] diff --git a/python/packages/a2a/agent_framework_a2a/_a2a_executor.py b/python/packages/a2a/agent_framework_a2a/_a2a_executor.py new file mode 100644 index 0000000000..0cf6d835a6 --- /dev/null +++ b/python/packages/a2a/agent_framework_a2a/_a2a_executor.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft. All rights reserved. + +import logging +from asyncio import CancelledError +from collections.abc import Mapping +from functools import partial +from typing import Any + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.server.tasks import TaskUpdater +from a2a.types import FilePart, FileWithBytes, FileWithUri, Part, TaskState, TextPart +from a2a.utils import new_task +from agent_framework import ( + AgentResponseUpdate, + AgentSession, + Message, + SupportsAgentRun, +) +from typing_extensions import override + +from agent_framework_a2a._utils import get_uri_data + +logger = logging.getLogger("agent_framework.a2a") + + +class A2AExecutor(AgentExecutor): + """Execute AI agents using the A2A (Agent-to-Agent) protocol. + + The A2AExecutor bridges AI agents built with the agent_framework library and the A2A protocol, + enabling structured agent execution with event-driven communication. It handles execution + contexts, delegates history management to the agent's session, and converts agent + responses into A2A protocol events. + + The executor supports executing an Agent or WorkflowAgent. It provides comprehensive + error handling with task status updates and supports various content types including text, + binary data, and URI-based content. + + Example: + .. code-block:: python + + from a2a.server.apps import A2AStarletteApplication + from a2a.server.request_handlers import DefaultRequestHandler + from a2a.server.tasks import InMemoryTaskStore + from a2a.types import AgentCapabilities, AgentCard + from agent_framework.a2a import A2AExecutor + from agent_framework.openai import OpenAIResponsesClient + + public_agent_card = AgentCard( + name="Food Agent", + description="A simple agent that provides food-related information.", + url="http://localhost:9999/", + version="1.0.0", + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=AgentCapabilities(streaming=True), + skills=[], + ) + + # Create an agent + agent = OpenAIResponsesClient().as_agent( + name="Food Agent", + instructions="A simple agent that provides food-related information.", + ) + + # Set up the A2A server with the A2AExecutor enabled for streaming + # and passing custom keyword arguments to the agent's run method. + request_handler = DefaultRequestHandler( + agent_executor=A2AExecutor(agent, stream=True, run_kwargs={"client_kwargs": {"max_tokens": 500}}), + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=public_agent_card, + http_handler=request_handler, + ).build() + + Args: + agent: The AI agent to execute. + stream: Whether to stream the agent response. Defaults to False. + run_kwargs: Additional keyword arguments to pass to the agent's run method. + """ + + def __init__(self, agent: SupportsAgentRun, stream: bool = False, run_kwargs: Mapping[str, Any] | None = None): + """Initialize the A2AExecutor with the specified agent. + + Args: + agent: The AI agent or workflow to execute. + stream: Whether to stream the agent response. Defaults to False. + run_kwargs: Additional keyword arguments to pass to the agent's run method. + Cannot contain 'session' or 'stream' as these are managed by the executor. + + Raises: + ValueError: If run_kwargs contains 'session' or 'stream'. + """ + super().__init__() + self._agent: SupportsAgentRun = agent + self._stream: bool = stream + if run_kwargs: + if "session" in run_kwargs: + raise ValueError("run_kwargs cannot contain 'session' as it is managed by the executor.") + if "stream" in run_kwargs: + raise ValueError("run_kwargs cannot contain 'stream' as it is managed by the executor.") + self._run_kwargs: Mapping[str, Any] = run_kwargs or {} + + @override + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel agent execution for the given request context. + + Uses a TaskUpdater to send a cancellation event through the provided event queue. + + Args: + context: The request context identifying the task to cancel. + event_queue: The event queue to publish the cancellation event to. + + Raises: + ValueError: If context_id is not provided in the RequestContext. + """ + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + + updater = TaskUpdater( + event_queue=event_queue, + task_id=context.task_id or "", + context_id=context.context_id, + ) + + await updater.cancel() + + @override + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Execute the agent with the given context and event queue. + + Orchestrates the agent execution process: sets up the agent session, + executes the agent, processes response messages, and handles errors with appropriate task status updates. + """ + if context.context_id is None: + raise ValueError("Context ID must be provided in the RequestContext") + if context.message is None: + raise ValueError("Message must be provided in the RequestContext") + + query = context.get_user_input() + task = context.current_task + + if not task: + task = new_task(context.message) + await event_queue.enqueue_event(task) + + updater = TaskUpdater(event_queue, task.id, context.context_id) + await updater.submit() + + try: + await updater.start_work() + + session = self._agent.create_session(session_id=task.context_id) + + if self._stream: + await self._run_stream(query, session, updater) + else: + await self._run(query, session, updater) + + # Mark as complete + await updater.complete() + except CancelledError: + await updater.update_status(state=TaskState.canceled, final=True) + except Exception as e: + logger.exception("A2AExecutor encountered an error during execution.", exc_info=e) + await updater.update_status( + state=TaskState.failed, + final=True, + message=updater.new_agent_message([Part(root=TextPart(text=str(e)))]), + ) + + async def _run_stream(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None: + """Run the agent in streaming mode and publish updates to the task updater.""" + response_stream = self._agent.run(query, session=session, stream=True, **self._run_kwargs) + streamed_artifact_ids: set[str] = set() + await ( + response_stream.with_transform_hook( + partial(self.handle_events, updater=updater, streamed_artifact_ids=streamed_artifact_ids) + ) + ).get_final_response() + + async def _run(self, query: Any, session: AgentSession, updater: TaskUpdater) -> None: + """Run the agent in non-streaming mode and publish messages to the task updater.""" + response = await self._agent.run(query, session=session, stream=False, **self._run_kwargs) + response_messages = response.messages + + if not isinstance(response_messages, list): + response_messages = [response_messages] + + for message in response_messages: + await self.handle_events(message, updater) + + async def handle_events( + self, item: Message | AgentResponseUpdate, updater: TaskUpdater, streamed_artifact_ids: set[str] | None = None + ) -> None: + """Convert agent response items (Messages or Updates) to A2A protocol events. + + Processes Message or AgentResponseUpdate objects and converts them into A2A protocol format. + Handles text, data, and URI content. USER role messages are skipped. + + Users can override this method in a subclass to implement custom transformations + from their agent's output format to A2A protocol events. + + Args: + item: The agent response item (Message or AgentResponseUpdate) to process. + updater: The task updater to publish events to. + streamed_artifact_ids: A set of artifact IDs that have already been streamed. + Used to prevent duplicate updates for the same artifact. + + Example: + .. code-block:: python + + class CustomA2AExecutor(A2AExecutor): + async def handle_events( + self, + item: Message | AgentResponseUpdate, + updater: TaskUpdater, + streamed_artifact_ids: set[str] | None = None, + ) -> None: + # Custom logic to transform item contents + if item.role == "assistant" and item.contents: + parts = [Part(root=TextPart(text=f"Custom: {item.contents[0].text}"))] + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message(parts=parts), + ) + else: + await super().handle_events(item, updater) + """ + role = getattr(item, "role", None) + if role == "user": + # This is a user message, we can ignore it in the context of task updates + return + + parts: list[Part] = [] + metadata = getattr(item, "additional_properties", None) + + # AgentResponseUpdate uses 'contents', Message uses 'contents' + contents = getattr(item, "contents", []) + + for content in contents: + if content.type == "text" and content.text: + parts.append(Part(root=TextPart(text=content.text))) + elif content.type == "data" and content.uri: + base64_str = get_uri_data(content.uri) + parts.append(Part(root=FilePart(file=FileWithBytes(bytes=base64_str, mime_type=content.media_type)))) + elif content.type == "uri" and content.uri: + parts.append(Part(root=FilePart(file=FileWithUri(uri=content.uri, mime_type=content.media_type)))) + else: + # Silently skip unsupported content types + logger.warning("A2AExecutor does not yet support content type: %s. Omitted.", content.type) + + if parts: + if isinstance(item, AgentResponseUpdate): + # For streaming updates, we send TaskArtifactUpdateEvent via add_artifact + await updater.add_artifact( + parts=parts, + artifact_id=item.message_id, + metadata=metadata, + append=( + True + if streamed_artifact_ids is not None and item.message_id in (streamed_artifact_ids or set()) + else None + ), + ) + if item.message_id and streamed_artifact_ids is not None: + streamed_artifact_ids.add(item.message_id) + else: + # For final messages, we send TaskStatusUpdateEvent with 'working' state + await updater.update_status( + state=TaskState.working, + message=updater.new_agent_message(parts=parts, metadata=metadata), + ) diff --git a/python/packages/a2a/agent_framework_a2a/_agent.py b/python/packages/a2a/agent_framework_a2a/_agent.py index 696a160cf6..7fa95cd07f 100644 --- a/python/packages/a2a/agent_framework_a2a/_agent.py +++ b/python/packages/a2a/agent_framework_a2a/_agent.py @@ -4,7 +4,6 @@ from __future__ import annotations import base64 import json -import re import uuid from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence from typing import Any, Final, Literal, TypeAlias, overload @@ -49,7 +48,7 @@ from agent_framework.observability import AgentTelemetryLayer __all__ = ["A2AAgent", "A2AContinuationToken"] -URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") +from agent_framework_a2a._utils import get_uri_data class A2AContinuationToken(ContinuationToken): @@ -78,14 +77,6 @@ A2AClientEvent: TypeAlias = tuple[Task, TaskStatusUpdateEvent | TaskArtifactUpda A2AStreamItem: TypeAlias = A2AMessage | A2AClientEvent -def _get_uri_data(uri: str) -> str: - match = URI_PATTERN.match(uri) - if not match: - raise ValueError(f"Invalid data URI format: {uri}") - - return match.group("base64_data") - - class A2AAgent(AgentTelemetryLayer, BaseAgent): """Agent2Agent (A2A) protocol implementation. @@ -652,7 +643,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent): A2APart( root=FilePart( file=FileWithBytes( - bytes=_get_uri_data(content.uri), + bytes=get_uri_data(content.uri), mime_type=content.media_type, ), metadata=content.additional_properties, diff --git a/python/packages/a2a/agent_framework_a2a/_utils.py b/python/packages/a2a/agent_framework_a2a/_utils.py new file mode 100644 index 0000000000..2b0a8e1600 --- /dev/null +++ b/python/packages/a2a/agent_framework_a2a/_utils.py @@ -0,0 +1,24 @@ +# Copyright (c) Microsoft. All rights reserved. + +import re + +URI_PATTERN = re.compile(r"^data:(?P[^;]+);base64,(?P[A-Za-z0-9+/=]+)$") + + +def get_uri_data(uri: str) -> str: + """Extracts the base64-encoded data from a data URI. + + Args: + uri: The data URI to parse. + + Returns: + The base64-encoded data part of the URI. + + Raises: + ValueError: If the URI format is invalid. + """ + match = URI_PATTERN.match(uri) + if not match: + raise ValueError(f"Invalid data URI format: {uri}") + + return match.group("base64_data") diff --git a/python/packages/a2a/tests/test_a2a_agent.py b/python/packages/a2a/tests/test_a2a_agent.py index dbbad8a865..909311e184 100644 --- a/python/packages/a2a/tests/test_a2a_agent.py +++ b/python/packages/a2a/tests/test_a2a_agent.py @@ -35,7 +35,7 @@ from agent_framework.a2a import A2AAgent from pytest import fixture, mark, raises from agent_framework_a2a import A2AContinuationToken -from agent_framework_a2a._agent import _get_uri_data # type: ignore +from agent_framework_a2a._utils import get_uri_data class MockA2AClient: @@ -353,18 +353,18 @@ def test_parse_message_from_artifact(a2a_agent: A2AAgent) -> None: def test_get_uri_data_valid_uri() -> None: - """Test _get_uri_data with valid data URI.""" + """Test get_uri_data with valid data URI.""" uri = "data:application/json;base64,eyJ0ZXN0IjoidmFsdWUifQ==" - result = _get_uri_data(uri) + result = get_uri_data(uri) assert result == "eyJ0ZXN0IjoidmFsdWUifQ==" def test_get_uri_data_invalid_uri() -> None: - """Test _get_uri_data with invalid URI format.""" + """Test get_uri_data with invalid URI format.""" with raises(ValueError, match="Invalid data URI format"): - _get_uri_data("not-a-valid-data-uri") + get_uri_data("not-a-valid-data-uri") def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None: diff --git a/python/packages/a2a/tests/test_a2a_executor.py b/python/packages/a2a/tests/test_a2a_executor.py new file mode 100644 index 0000000000..bd3ead046e --- /dev/null +++ b/python/packages/a2a/tests/test_a2a_executor.py @@ -0,0 +1,910 @@ +# Copyright (c) Microsoft. All rights reserved. +from asyncio import CancelledError +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from a2a.types import Task, TaskState, TextPart +from agent_framework import ( + AgentResponseUpdate, + Content, + Message, + SupportsAgentRun, +) +from agent_framework._types import AgentResponse +from agent_framework.a2a import A2AExecutor +from pytest import fixture, raises + + +@fixture +def mock_agent() -> MagicMock: + """Fixture that provides a mock SupportsAgentRun.""" + agent = MagicMock(spec=SupportsAgentRun) + agent.run = AsyncMock() + return agent + + +@fixture +def mock_request_context() -> MagicMock: + """Fixture that provides a mock RequestContext.""" + request_context = MagicMock() + request_context.context_id = str(uuid4()) + request_context.get_user_input = MagicMock(return_value="Test query") + request_context.current_task = None + request_context.message = None + return request_context + + +@fixture +def mock_event_queue() -> MagicMock: + """Fixture that provides a mock EventQueue.""" + queue = AsyncMock() + queue.enqueue_event = AsyncMock() + return queue + + +@fixture +def mock_task() -> Task: + """Fixture that provides a mock Task.""" + task = MagicMock(spec=Task) + task.id = str(uuid4()) + task.context_id = str(uuid4()) + task.state = TaskState.completed + return task + + +@fixture +def mock_task_updater() -> MagicMock: + """Fixture that provides a mock TaskUpdater.""" + updater = MagicMock() + updater.submit = AsyncMock() + updater.start_work = AsyncMock() + updater.complete = AsyncMock() + updater.update_status = AsyncMock() + updater.new_agent_message = MagicMock() + return updater + + +@fixture +def executor(mock_agent: MagicMock) -> A2AExecutor: + """Fixture that provides an A2AExecutor.""" + return A2AExecutor(agent=mock_agent) + + +class TestA2AExecutorInitialization: + """Tests for A2AExecutor initialization.""" + + def test_initialization_with_agent_only(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with only agent + Assert: Executor is created with default values + """ + # Act + executor = A2AExecutor(agent=mock_agent) + + # Assert + assert executor._agent is mock_agent + assert executor._stream is False + assert executor._run_kwargs == {} + + def test_initialization_with_stream_and_kwargs(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with stream and run_kwargs + Assert: Executor is created with specified values + """ + # Arrange + run_kwargs = {"temperature": 0.5} + + # Act + executor = A2AExecutor(agent=mock_agent, stream=True, run_kwargs=run_kwargs) + + # Assert + assert executor._agent is mock_agent + assert executor._stream is True + assert executor._run_kwargs == run_kwargs + + def test_initialization_with_invalid_run_kwargs(self, mock_agent: MagicMock) -> None: + """Arrange: Create mock agent + Act: Initialize A2AExecutor with reserved keys in run_kwargs + Assert: ValueError is raised + """ + # Act & Assert + with raises(ValueError, match="run_kwargs cannot contain 'session'"): + A2AExecutor(agent=mock_agent, run_kwargs={"session": "something"}) + + with raises(ValueError, match="run_kwargs cannot contain 'stream'"): + A2AExecutor(agent=mock_agent, run_kwargs={"stream": True}) + + +class TestA2AExecutorCancel: + """Tests for the cancel method.""" + + async def test_cancel_method_completes( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with dependencies + Act: Call cancel method + Assert: Method completes without raising error + """ + # Arrange + mock_request_context.task_id = "task-123" + + # Act & Assert (should not raise) + await executor.cancel(mock_request_context, mock_event_queue) # type: ignore + + async def test_cancel_handles_different_contexts( + self, + executor: A2AExecutor, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with multiple request contexts + Act: Call cancel with different contexts + Assert: Each cancel completes successfully + """ + # Arrange + context1 = MagicMock() + context1.context_id = "ctx-1" + context1.task_id = "task-1" + context2 = MagicMock() + context2.context_id = "ctx-2" + context2.task_id = "task-2" + + # Act & Assert + await executor.cancel(context1, mock_event_queue) # type: ignore + await executor.cancel(context2, mock_event_queue) # type: ignore + + async def test_cancel_raises_error_when_context_id_missing( + self, + executor: A2AExecutor, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without context_id + Act: Call cancel method + Assert: ValueError is raised + """ + # Arrange + mock_context = MagicMock() + mock_context.context_id = None + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.cancel(mock_context, mock_event_queue) # type: ignore + + # Assert + assert "Context ID" in str(excinfo.value) + + +class TestA2AExecutorExecute: + """Tests for the execute method.""" + + async def test_execute_with_existing_task_succeeds( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with mocked dependencies and existing task + Act: Call execute method + Assert: Execution completes successfully + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Hello back")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.submit.assert_called_once() + mock_updater.start_work.assert_called_once() + mock_updater.complete.assert_called_once() + executor._agent.create_session.assert_called_once() + executor._agent.run.assert_called_once() + + async def test_execute_creates_task_when_not_exists( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create executor with request context without task + Act: Call execute method + Assert: New task is created and enqueued + """ + # Arrange + mock_message = MagicMock() + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = None + mock_request_context.message = mock_message + mock_request_context.context_id = "ctx-123" + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.new_task") as mock_new_task: + mock_task = MagicMock(spec=Task) + mock_task.id = "task-new" + mock_task.context_id = "ctx-123" + mock_new_task.return_value = mock_task + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_new_task.assert_called_once() + mock_event_queue.enqueue_event.assert_called_once() + + async def test_execute_raises_error_when_context_id_missing( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without context_id + Act: Call execute method + Assert: ValueError is raised + """ + # Arrange + mock_request_context.context_id = None + mock_request_context.message = MagicMock() + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert "Context ID" in str(excinfo.value) + + async def test_execute_raises_error_when_message_missing( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + ) -> None: + """Arrange: Create context without message + Act: Call execute method + Assert: ValueError is raised + """ + # Arrange + mock_request_context.context_id = "ctx-123" + mock_request_context.message = None + + # Act & Assert + with raises(ValueError) as excinfo: + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert "Message" in str(excinfo.value) + + async def test_execute_handles_cancelled_error( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that raises CancelledError + Act: Call execute method + Assert: Error is caught and task is marked as canceled + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + executor._agent.run = AsyncMock(side_effect=CancelledError()) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) # type: ignore + + # Assert + mock_updater.update_status.assert_called() + call_args_list = mock_updater.update_status.call_args_list + assert any( + call[1].get("state") == TaskState.canceled and call[1].get("final") is True for call in call_args_list + ) + + async def test_execute_handles_generic_exception( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that raises generic exception + Act: Call execute method + Assert: Error is caught and task is marked as failed + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + error_message = "Test error" + executor._agent.run = AsyncMock(side_effect=ValueError(error_message)) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="error_message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.new_agent_message.assert_called_once() + args, _ = mock_updater.new_agent_message.call_args + parts = args[0] + assert len(parts) == 1 + assert isinstance(parts[0].root, TextPart) + assert parts[0].root.text == error_message + + call_args_list = mock_updater.update_status.call_args_list + assert any( + call[1].get("state") == TaskState.failed + and call[1].get("final") is True + and call[1].get("message") == "error_message_obj" + for call in call_args_list + ) + + async def test_execute_processes_multiple_response_messages( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor that returns multiple response messages + Act: Call execute method + Assert: All messages are processed through handle_events + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message1 = Message(role="assistant", contents=[Content.from_text(text="First")]) + response_message2 = Message(role="assistant", contents=[Content.from_text(text="Second")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message1, response_message2] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + # Mock handle_events + executor.handle_events = AsyncMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + assert executor.handle_events.call_count == 2 + + async def test_execute_passes_query_to_run( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with request + Act: Call execute method + Assert: Query text is passed to run method with default stream and kwargs + """ + # Arrange + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater.new_agent_message = MagicMock(return_value="message_obj") + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + executor._agent.run.assert_called_once_with( + query_text, session=executor._agent.create_session(), stream=False + ) + + async def test_execute_with_stream_enabled( + self, + mock_agent: MagicMock, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with stream=True + Act: Call execute method + Assert: _run_stream is called and passes stream=True to run + """ + # Arrange + executor = A2AExecutor(agent=mock_agent, stream=True) + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + mock_response_stream = MagicMock() + mock_response_stream.with_transform_hook = MagicMock(return_value=mock_response_stream) + mock_response_stream.get_final_response = AsyncMock() + mock_agent.run = MagicMock(return_value=mock_response_stream) + mock_agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_agent.run.assert_called_once_with(query_text, session=mock_agent.create_session(), stream=True) + mock_response_stream.with_transform_hook.assert_called_once() + mock_response_stream.get_final_response.assert_called_once() + + async def test_execute_with_run_kwargs( + self, + mock_agent: MagicMock, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with run_kwargs + Act: Call execute method + Assert: run_kwargs are passed to run method + """ + # Arrange + run_kwargs = {"temperature": 0.5, "max_tokens": 100} + executor = A2AExecutor(agent=mock_agent, run_kwargs=run_kwargs) + query_text = "Hello agent" + mock_request_context.get_user_input = MagicMock(return_value=query_text) + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = [response_message] + mock_agent.run = AsyncMock(return_value=response) + mock_agent.create_session = MagicMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_agent.run.assert_called_once_with( + query_text, session=mock_agent.create_session(), stream=False, **run_kwargs + ) + + +class TestA2AExecutorHandleEvents: + """Tests for A2AExecutor.handle_events method.""" + + async def test_run_method_with_single_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test the private _run method with a single message (not a list).""" + # Arrange + query = "test query" + session = MagicMock() + response_message = Message(role="assistant", contents=[Content.from_text(text="Response")]) + response = MagicMock(spec=AgentResponse) + response.messages = response_message # Not a list + executor._agent.run = AsyncMock(return_value=response) + executor.handle_events = AsyncMock() + + # Act + await executor._run(query, session, mock_updater) + + # Assert + executor.handle_events.assert_called_once_with(response_message, mock_updater) + + @fixture + def mock_updater(self) -> MagicMock: + """Create a mock execution context.""" + updater = MagicMock() + updater.update_status = AsyncMock() + updater.new_agent_message = MagicMock(return_value="mock_message") + return updater + + async def test_ignore_user_messages(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that messages from USER role are ignored.""" + # Arrange + message = Message( + contents=[Content.from_text(text="User input")], + role="user", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_not_called() + + async def test_ignore_messages_with_no_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that messages with no contents are ignored.""" + # Arrange + message = Message( + contents=[], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_not_called() + + async def test_handle_text_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with text content.""" + # Arrange + text = "Hello, this is a test message" + message = Message( + contents=[Content.from_text(text=text)], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + assert mock_updater.new_agent_message.called + + async def test_handle_multiple_text_contents(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with multiple text contents.""" + # Arrange + message = Message( + contents=[ + Content.from_text(text="First message"), + Content.from_text(text="Second message"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + assert mock_updater.new_agent_message.called + + async def test_handle_data_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with data content.""" + # Arrange + data = b"test file data" + message = Message( + contents=[Content.from_data(data=data, media_type="application/octet-stream")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_uri_content(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with URI content.""" + # Arrange + uri = "https://example.com/file.pdf" + message = Message( + contents=[Content.from_uri(uri=uri, media_type="application/pdf")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_mixed_content_types(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with mixed content types.""" + # Arrange + data = b"file data" + + message = Message( + contents=[ + Content.from_text(text="Processing file..."), + Content.from_data(data=data, media_type="application/octet-stream"), + Content.from_uri(uri="https://example.com/reference.pdf", media_type="application/pdf"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + call_args = mock_updater.update_status.call_args + assert call_args.kwargs["state"] == TaskState.working + + async def test_handle_with_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with additional properties metadata.""" + # Arrange + additional_props = {"custom_field": "custom_value", "priority": "high"} + message = Message( + contents=[Content.from_text(text="Test message")], + role="assistant", + additional_properties=additional_props, + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + mock_updater.new_agent_message.assert_called_once() + call_args = mock_updater.new_agent_message.call_args + assert call_args.kwargs["metadata"] == additional_props + + async def test_handle_with_no_additional_properties(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages without additional properties.""" + # Arrange + message = Message( + contents=[Content.from_text(text="Test message")], + role="assistant", + additional_properties=None, + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.update_status.assert_called_once() + mock_updater.new_agent_message.assert_called_once() + call_args = mock_updater.new_agent_message.call_args + assert call_args.kwargs["metadata"] == {} + + async def test_parts_list_passed_to_new_agent_message(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that parts list is correctly passed to new_agent_message.""" + # Arrange + message = Message( + contents=[ + Content.from_text(text="Message 1"), + Content.from_text(text="Message 2"), + ], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + mock_updater.new_agent_message.assert_called_once() + call_kwargs = mock_updater.new_agent_message.call_args.kwargs + assert "parts" in call_kwargs + parts_list = call_kwargs["parts"] + assert len(parts_list) == 2 + + async def test_task_state_always_working(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test that task state is always set to working.""" + # Arrange + message = Message( + contents=[Content.from_text(text="Any message")], + role="assistant", + ) + + # Act + await executor.handle_events(message, mock_updater) + + # Assert + call_kwargs = mock_updater.update_status.call_args.kwargs + assert call_kwargs["state"] == TaskState.working + + async def test_handle_agent_response_update_no_streamed_set( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) without a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Streaming chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + + # Act + await executor.handle_events(update, mock_updater) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["artifact_id"] == "msg-1" + assert call_kwargs["append"] is None + + async def test_handle_agent_response_update_first_time( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) for the first time with a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Streaming chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + streamed_artifact_ids = set() + + # Act + await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["append"] is None + assert "msg-1" in streamed_artifact_ids + + async def test_handle_agent_response_update_subsequent_time( + self, executor: A2AExecutor, mock_updater: MagicMock + ) -> None: + """Test handling AgentResponseUpdate (streaming) for subsequent times with a tracking set.""" + # Arrange + update = AgentResponseUpdate( + contents=[Content.from_text(text="Next chunk")], + role="assistant", + message_id="msg-1", + ) + mock_updater.add_artifact = AsyncMock() + streamed_artifact_ids = {"msg-1"} + + # Act + await executor.handle_events(update, mock_updater, streamed_artifact_ids=streamed_artifact_ids) + + # Assert + mock_updater.add_artifact.assert_called_once() + call_kwargs = mock_updater.add_artifact.call_args.kwargs + assert call_kwargs["append"] is True + + async def test_handle_unsupported_content_type(self, executor: A2AExecutor, mock_updater: MagicMock) -> None: + """Test handling messages with unsupported content types.""" + # Arrange + message = Message( + contents=[Content(type="unknown", text="Some text")], + role="assistant", + ) + + # Act + with patch("agent_framework_a2a._a2a_executor.logger") as mock_logger: + await executor.handle_events(message, mock_updater) + + # Assert + mock_logger.warning.assert_called_once() + mock_updater.update_status.assert_not_called() + + +class TestA2AExecutorIntegration: + """Integration tests for A2AExecutor.""" + + async def test_full_execution_flow_with_responses( + self, + executor: A2AExecutor, + mock_request_context: MagicMock, + mock_event_queue: MagicMock, + mock_task: Task, + ) -> None: + """Arrange: Create executor with all mocked dependencies + Act: Execute full flow from request to completion + Assert: All components interact correctly + """ + # Arrange + mock_request_context.get_user_input = MagicMock(return_value="Hello agent") + mock_request_context.current_task = mock_task + mock_request_context.context_id = "ctx-123" + mock_request_context.message = MagicMock() + + response = MagicMock(spec=AgentResponse) + response_message = MagicMock(spec=Message) + response.messages = [response_message] + response_message.contents = [Content.from_text(text="Hello user")] + response_message.role = "assistant" + response_message.additional_properties = None + + executor._agent.run = AsyncMock(return_value=response) + executor._agent.create_session = MagicMock() + executor.handle_events = AsyncMock() + + with patch("agent_framework_a2a._a2a_executor.TaskUpdater") as mock_updater_class: + mock_updater = MagicMock() + mock_updater.submit = AsyncMock() + mock_updater.start_work = AsyncMock() + mock_updater.complete = AsyncMock() + mock_updater.update_status = AsyncMock() + mock_updater_class.return_value = mock_updater + + # Act + await executor.execute(mock_request_context, mock_event_queue) + + # Assert + mock_updater.submit.assert_called_once() + mock_updater.start_work.assert_called_once() + executor.handle_events.assert_called_once() + mock_updater.complete.assert_called_once() diff --git a/python/packages/a2a/tests/test_utils.py b/python/packages/a2a/tests/test_utils.py new file mode 100644 index 0000000000..2c73b2e7cf --- /dev/null +++ b/python/packages/a2a/tests/test_utils.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft. All rights reserved. + +import pytest + +from agent_framework_a2a._utils import get_uri_data + + +def test_get_uri_data_valid() -> None: + """Test get_uri_data with valid data URIs.""" + # Simple text/plain + uri = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" + assert get_uri_data(uri) == "SGVsbG8sIFdvcmxkIQ==" + + # Image png + uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + assert get_uri_data(uri) == "iVBORw0KGgoAAAANSUhEUgfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" + + # Application octet-stream + uri = "data:application/octet-stream;base64,AQIDBA==" + assert get_uri_data(uri) == "AQIDBA==" + + +def test_get_uri_data_invalid_format() -> None: + """Test get_uri_data with invalid URI formats.""" + invalid_uris = [ + "not-a-uri", + "http://example.com", + "data:text/plain;SGVsbG8sIFdvcmxkIQ==", # Missing base64 marker + "data:base64,SGVsbG8sIFdvcmxkIQ==", # Missing media type + "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ==", # Extra parameters (current regex doesn't support) + "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ== extra", + ] + for uri in invalid_uris: + with pytest.raises(ValueError, match="Invalid data URI format"): + get_uri_data(uri) + + +def test_get_uri_data_empty() -> None: + """Test get_uri_data with empty string.""" + with pytest.raises(ValueError, match="Invalid data URI format"): + get_uri_data("") diff --git a/python/packages/core/agent_framework/a2a/__init__.py b/python/packages/core/agent_framework/a2a/__init__.py index 7c7de63456..90daf5380a 100644 --- a/python/packages/core/agent_framework/a2a/__init__.py +++ b/python/packages/core/agent_framework/a2a/__init__.py @@ -7,6 +7,7 @@ This module lazily re-exports objects from: Supported classes: - A2AAgent +- A2AExecutor """ import importlib @@ -14,7 +15,7 @@ from typing import Any IMPORT_PATH = "agent_framework_a2a" PACKAGE_NAME = "agent-framework-a2a" -_IMPORTS = ["A2AAgent"] +_IMPORTS = ["A2AAgent", "A2AExecutor"] def __getattr__(name: str) -> Any: diff --git a/python/packages/core/agent_framework/a2a/__init__.pyi b/python/packages/core/agent_framework/a2a/__init__.pyi index 5a54bb22a9..65aa8f1a37 100644 --- a/python/packages/core/agent_framework/a2a/__init__.pyi +++ b/python/packages/core/agent_framework/a2a/__init__.pyi @@ -1,9 +1,5 @@ # Copyright (c) Microsoft. All rights reserved. -from agent_framework_a2a import ( - A2AAgent, -) +from agent_framework_a2a import A2AAgent, A2AExecutor -__all__ = [ - "A2AAgent", -] +__all__ = ["A2AAgent", "A2AExecutor"] diff --git a/python/samples/04-hosting/a2a/README.md b/python/samples/04-hosting/a2a/README.md index f377eed8ba..aca25a4dab 100644 --- a/python/samples/04-hosting/a2a/README.md +++ b/python/samples/04-hosting/a2a/README.md @@ -12,6 +12,7 @@ The remaining files are supporting modules used by the server: | File | Description | |------|-------------| +| [`agent_framework_to_a2a.py`](agent_framework_to_a2a.py) | Exposes an agent_framework agent as an A2A-compliant server. Demonstrates how to wrap an agent_framework agent and expose it as an A2A service that other A2A clients can discover and communicate with. | | [`agent_definitions.py`](agent_definitions.py) | Agent and AgentCard factory definitions for invoice, policy, and logistics agents. | | [`agent_executor.py`](agent_executor.py) | Bridges the a2a-sdk `AgentExecutor` interface to Agent Framework agents. | | [`invoice_data.py`](invoice_data.py) | Mock invoice data and tool functions for the invoice agent. | @@ -60,6 +61,9 @@ In a separate terminal (from the same directory), point the client at a running ```powershell $env:A2A_AGENT_HOST = "http://localhost:5001/" uv run python agent_with_a2a.py + +# A2A server exposing an agent_framework agent +uv run python agent_framework_to_a2a.py ``` ### 3. Run the Function Tools Sample diff --git a/python/samples/04-hosting/a2a/agent_framework_to_a2a.py b/python/samples/04-hosting/a2a/agent_framework_to_a2a.py new file mode 100644 index 0000000000..0693b61b0a --- /dev/null +++ b/python/samples/04-hosting/a2a/agent_framework_to_a2a.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft. All rights reserved. + +import uvicorn +from a2a.server.apps import A2AStarletteApplication +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, +) +from agent_framework import Agent +from agent_framework.a2a import A2AExecutor +from agent_framework.openai import OpenAIChatClient +from dotenv import load_dotenv + +load_dotenv() + +if __name__ == "__main__": + # --8<-- [start:AgentSkill] + flight_skill = AgentSkill( + id="Flight_Booking", + name="Flight Booking", + description="Search and book flights across Europe.", + tags=["flights", "travel", "europe"], + examples=[], + ) + hotel_skill = AgentSkill( + id="Hotel_Booking", + name="Hotel Booking", + description="Search and book hotels across Europe.", + tags=["hotels", "travel", "accommodation"], + examples=[], + ) + # --8<-- [end:AgentSkill] + + # --8<-- [start:AgentCard] + # This will be the public-facing agent card + public_agent_card = AgentCard( + name="Europe Travel Agent", + description="A helpful Europe Travel Agent that can help users search and book flights and hotels across Europe.", + url="http://localhost:9999/", + version="1.0.0", + defaultInputModes=["text"], + defaultOutputModes=["text"], + capabilities=AgentCapabilities(streaming=True), + skills=[flight_skill, hotel_skill], + ) + # --8<-- [end:AgentCard] + + agent = Agent( + client=OpenAIChatClient(), + name="Europe Travel Agent", + instructions="You are a helpful Europe Travel Agent. You can help users search and book flights and hotels across Europe." + ) + + request_handler = DefaultRequestHandler( + agent_executor=A2AExecutor(agent), + task_store=InMemoryTaskStore(), + ) + + server = A2AStarletteApplication( + agent_card=public_agent_card, + http_handler=request_handler, + ) + + server = server.build() + # print(schemas.get_schema(server.routes)) + + uvicorn.run(server, host="0.0.0.0", port=9999) From 63c0a517970722e0765702019958a1830e055348 Mon Sep 17 00:00:00 2001 From: Dineshsuriya D <43177361+droideronline@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:14:44 +0530 Subject: [PATCH 48/52] Python: Add OpenTelemetry integration for GitHubCopilotAgent (#5142) * Python: Add OpenTelemetry integration for GitHubCopilotAgent - Split GitHubCopilotAgent into RawGitHubCopilotAgent (core, no OTel) and GitHubCopilotAgent(AgentTelemetryLayer, RawGitHubCopilotAgent) with tracing - Add default_options property to expose model for span attributes - Export RawGitHubCopilotAgent from all public namespaces - Add github_copilot_with_observability.py sample and update README * Python: Fix OTEL_SERVICE_NAME default in GitHub Copilot README Co-Authored-By: Claude Sonnet 4.6 * Python: Add unit tests for RawGitHubCopilotAgent.default_options property * Python: Address review feedback on GitHubCopilotAgent OTel integration - Add middleware param to GitHubCopilotAgent.run() overloads so per-call middleware is explicitly forwarded through AgentTelemetryLayer - Remove github_copilot_with_observability.py sample per feedback; replace with inline snippet + link to observability samples in README * Python: Address review feedback on log_level and session kwargs typing - Add middleware param to RawGitHubCopilotAgent.run() overloads for interface compatibility with AgentTelemetryLayer - Fix import in README observability snippet to use agent_framework.github * Python: Add AgentMiddlewareLayer to GitHubCopilotAgent MRO Follow FoundryAgent pattern: AgentMiddlewareLayer runs outside the telemetry span so middleware execution time is not captured in traces. Overloads removed as AgentMiddlewareLayer.run() handles dispatch via MRO. * Python: Add explicit __init__ to GitHubCopilotAgent for auto-complete and docstrings * Python: Address review feedback on middleware warning and test assertions - Add assert "timeout" not in opts to test_default_options_includes_model_for_telemetry to document the intentional asymmetry where timeout is extracted into _settings and not returned in default_options. - Replace silent del middleware with a logged warning when per-run middleware is passed to RawGitHubCopilotAgent, making it clear that the GitHub Copilot SDK handles tool execution internally and chat/function middleware cannot be injected. * Python: Use Self for __aenter__ return type in RawGitHubCopilotAgent Address review feedback: use typing.Self (3.11+) / typing_extensions.Self (3.10) for __aenter__ so subclasses like GitHubCopilotAgent get the correct return type from async context manager usage. --------- Co-authored-by: Claude Sonnet 4.6 --- .../core/agent_framework/github/__init__.py | 2 + .../core/agent_framework/github/__init__.pyi | 2 + .../__init__.py | 3 +- .../agent_framework_github_copilot/_agent.py | 164 +++++++++++++++--- .../tests/test_github_copilot_agent.py | 25 +++ .../providers/github_copilot/README.md | 16 ++ 6 files changed, 191 insertions(+), 21 deletions(-) diff --git a/python/packages/core/agent_framework/github/__init__.py b/python/packages/core/agent_framework/github/__init__.py index 831a838c0d..0ff654fa17 100644 --- a/python/packages/core/agent_framework/github/__init__.py +++ b/python/packages/core/agent_framework/github/__init__.py @@ -9,6 +9,7 @@ Supported classes: - GitHubCopilotAgent - GitHubCopilotOptions - GitHubCopilotSettings +- RawGitHubCopilotAgent """ import importlib @@ -18,6 +19,7 @@ _IMPORTS: dict[str, tuple[str, str]] = { "GitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), "GitHubCopilotOptions": ("agent_framework_github_copilot", "agent-framework-github-copilot"), "GitHubCopilotSettings": ("agent_framework_github_copilot", "agent-framework-github-copilot"), + "RawGitHubCopilotAgent": ("agent_framework_github_copilot", "agent-framework-github-copilot"), } diff --git a/python/packages/core/agent_framework/github/__init__.pyi b/python/packages/core/agent_framework/github/__init__.pyi index 567ab9490d..f7b68966cf 100644 --- a/python/packages/core/agent_framework/github/__init__.pyi +++ b/python/packages/core/agent_framework/github/__init__.pyi @@ -4,10 +4,12 @@ from agent_framework_github_copilot import ( GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, + RawGitHubCopilotAgent, ) __all__ = [ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py index 432427fd9d..56b46f8dee 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/__init__.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings +from ._agent import GitHubCopilotAgent, GitHubCopilotOptions, GitHubCopilotSettings, RawGitHubCopilotAgent try: __version__ = importlib.metadata.version(__name__) @@ -13,5 +13,6 @@ __all__ = [ "GitHubCopilotAgent", "GitHubCopilotOptions", "GitHubCopilotSettings", + "RawGitHubCopilotAgent", "__version__", ] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 8015f561d9..b7fc709773 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -9,7 +9,13 @@ import sys from collections.abc import AsyncIterable, Awaitable, Callable, MutableMapping, Sequence from typing import Any, ClassVar, Generic, Literal, TypedDict, overload +if sys.version_info >= (3, 11): + from typing import Self # pragma: no cover +else: + from typing_extensions import Self # pragma: no cover + from agent_framework import ( + AgentMiddlewareLayer, AgentMiddlewareTypes, AgentResponse, AgentResponseUpdate, @@ -27,6 +33,7 @@ from agent_framework._settings import load_settings from agent_framework._tools import FunctionTool, ToolTypes from agent_framework._types import AgentRunInputs, normalize_tools from agent_framework.exceptions import AgentException +from agent_framework.observability import AgentTelemetryLayer try: from copilot import CopilotClient, CopilotSession, SubprocessConfig @@ -135,8 +142,11 @@ OptionsT = TypeVar( ) -class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): - """A GitHub Copilot Agent. +class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]): + """A GitHub Copilot Agent without telemetry layers. + + This is the core GitHub Copilot agent implementation without OpenTelemetry instrumentation. + For most use cases, prefer :class:`GitHubCopilotAgent` which includes telemetry support. This agent wraps the GitHub Copilot SDK to provide Copilot agentic capabilities within the Agent Framework. It supports both streaming and non-streaming responses, @@ -149,7 +159,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - async with GitHubCopilotAgent() as agent: + async with RawGitHubCopilotAgent() as agent: response = await agent.run("Hello, world!") print(response) @@ -157,22 +167,11 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): .. code-block:: python - from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + from agent_framework_github_copilot import RawGitHubCopilotAgent, GitHubCopilotOptions - agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + agent: RawGitHubCopilotAgent[GitHubCopilotOptions] = RawGitHubCopilotAgent( default_options={"model": "claude-sonnet-4", "timeout": 120} ) - - With tools: - - .. code-block:: python - - def get_weather(city: str) -> str: - return f"Weather in {city} is sunny" - - - async with GitHubCopilotAgent(tools=[get_weather]) as agent: - response = await agent.run("What's the weather in Seattle?") """ AGENT_PROVIDER_NAME: ClassVar[str] = "github.copilot" @@ -200,9 +199,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: client: Optional pre-configured CopilotClient instance. If not provided, a new client will be created using the other parameters. - id: ID of the GitHubCopilotAgent. - name: Name of the GitHubCopilotAgent. - description: Description of the GitHubCopilotAgent. + id: ID of the RawGitHubCopilotAgent. + name: Name of the RawGitHubCopilotAgent. + description: Description of the RawGitHubCopilotAgent. context_providers: Context Providers, to be used by the agent. middleware: Agent middleware used by the agent. tools: Tools to use for the agent. Can be functions @@ -258,7 +257,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): self._default_options = opts self._started = False - async def __aenter__(self) -> GitHubCopilotAgent[OptionsT]: + async def __aenter__(self) -> Self: """Start the agent when entering async context.""" await self.start() return self @@ -308,6 +307,20 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): self._started = False + @property + def default_options(self) -> dict[str, Any]: + """Expose default options including model from settings. + + Returns a merged dict of ``_default_options`` with the resolved ``model`` + from settings injected under the ``model`` key. This is read by + :class:`AgentTelemetryLayer` to include the model name in span attributes. + """ + opts = dict(self._default_options) + model = self._settings.get("model") + if model: + opts["model"] = model + return opts + @overload def run( self, @@ -315,7 +328,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[False] = False, session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> Awaitable[AgentResponse]: ... @overload @@ -325,7 +340,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: Literal[True], session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... def run( @@ -334,7 +351,9 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): *, stream: bool = False, session: AgentSession | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, options: OptionsT | None = None, + **kwargs: Any, # type: ignore[override] ) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]: """Get a response from the agent. @@ -348,7 +367,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Keyword Args: stream: Whether to stream the response. Defaults to False. session: The conversation session associated with the message(s). + middleware: Not used by this agent directly. Accepted for interface + compatibility; pass middleware via :class:`GitHubCopilotAgent` which + forwards it through :class:`AgentTelemetryLayer`. options: Runtime options (model, timeout, etc.). + kwargs: Additional keyword arguments for compatibility with the shared agent + interface (e.g. compaction_strategy, tokenizer). Not used by this agent. Returns: When stream=False: An Awaitable[AgentResponse]. @@ -357,6 +381,12 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): Raises: AgentException: If the request fails. """ + if middleware: + logger.warning( + "Per-run middleware is not supported by RawGitHubCopilotAgent: the GitHub Copilot SDK " + "handles tool execution internally, so chat/function middleware cannot be injected into " + "the tool call path. Use agent-level middleware via the GitHubCopilotAgent constructor instead." + ) if stream: ctx_holder: dict[str, Any] = {} @@ -767,3 +797,97 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): mcp_servers=self._mcp_servers or None, provider=self._provider or None, ) + + +class GitHubCopilotAgent( # type: ignore[misc] + AgentMiddlewareLayer, + AgentTelemetryLayer, + RawGitHubCopilotAgent[OptionsT], + Generic[OptionsT], +): + """A GitHub Copilot Agent with full middleware and telemetry support. + + This is the recommended agent class for most use cases. It includes + middleware support and OpenTelemetry-based telemetry for observability, + with middleware running outside the telemetry span so middleware execution + time is not captured in traces. For a minimal implementation without these + layers, use :class:`RawGitHubCopilotAgent`. + + Examples: + Basic usage: + + .. code-block:: python + + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + print(response) + + With explicitly typed options: + + .. code-block:: python + + from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions + + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4-5", "timeout": 120} + ) + + With observability: + + .. code-block:: python + + from agent_framework.observability import configure_otel_providers + + configure_otel_providers() + async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello, world!") + """ + + def __init__( + self, + instructions: str | None = None, + *, + client: CopilotClient | None = None, + id: str | None = None, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[ContextProvider] | None = None, + middleware: Sequence[AgentMiddlewareTypes] | None = None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None, + default_options: OptionsT | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize a GitHub Copilot Agent with full middleware and telemetry. + + Args: + instructions: System message for the agent. + + Keyword Args: + client: Optional pre-configured CopilotClient instance. If not provided, + a new client will be created using the other parameters. + id: ID of the agent. + name: Name of the agent. + description: Description of the agent. + context_providers: Context providers to be used by the agent. + middleware: Agent middleware used by the agent. + tools: Tools to use for the agent. Can be functions or tool definition dicts. + These are converted to Copilot SDK tools internally. + default_options: Default options for the agent. Can include cli_path, model, + timeout, log_level, etc. + env_file_path: Optional path to .env file for loading configuration. + env_file_encoding: Encoding of the .env file, defaults to 'utf-8'. + """ + super().__init__( + instructions, + client=client, + id=id, + name=name, + description=description, + context_providers=context_providers, + middleware=middleware, + tools=tools, + default_options=default_options, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 9e1459db93..4d953d9c36 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -189,6 +189,31 @@ class TestGitHubCopilotAgentInit: "content": "Direct instructions", } + def test_default_options_includes_model_for_telemetry(self) -> None: + """Test that default_options merges model from settings for AgentTelemetryLayer span attributes.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "claude-sonnet-4-5", "timeout": 120} + ) + opts = agent.default_options + assert opts["model"] == "claude-sonnet-4-5" + assert "timeout" not in opts # timeout is extracted into _settings, not returned in default_options + + def test_default_options_without_model_configured(self) -> None: + """Test that default_options works correctly when no model is configured.""" + agent = GitHubCopilotAgent(instructions="Helper") + opts = agent.default_options + assert "model" not in opts + assert opts.get("system_message") == {"mode": "append", "content": "Helper"} + + def test_default_options_returns_independent_copy(self) -> None: + """Test that mutating the returned dict does not affect internal state.""" + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( + default_options={"model": "gpt-5.1-mini"} + ) + opts = agent.default_options + opts["model"] = "mutated" + assert agent._settings.get("model") == "gpt-5.1-mini" + class TestGitHubCopilotAgentLifecycle: """Test cases for agent lifecycle management.""" diff --git a/python/samples/02-agents/providers/github_copilot/README.md b/python/samples/02-agents/providers/github_copilot/README.md index 572ec9c444..5f8ee344d6 100644 --- a/python/samples/02-agents/providers/github_copilot/README.md +++ b/python/samples/02-agents/providers/github_copilot/README.md @@ -24,6 +24,22 @@ The following environment variables can be configured: | `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` | | `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` | +## Observability + +`GitHubCopilotAgent` has OpenTelemetry tracing built-in. To enable it, call `configure_otel_providers()` before running the agent: + +```python +from agent_framework.observability import configure_otel_providers +from agent_framework.github import GitHubCopilotAgent + +configure_otel_providers(enable_console_exporters=True) + +async with GitHubCopilotAgent() as agent: + response = await agent.run("Hello!") +``` + +See the [observability samples](../../../02-agents/observability/) for full examples with OTLP exporters. + ## Examples | File | Description | From 62e02da698b4b85932323b3067154071b41d74d7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 24 Apr 2026 11:25:03 +0200 Subject: [PATCH 49/52] Python: update FoundryAgent for hosted agent sessions (#5447) * fixes to FoundryAgent to connect to new hosted agents Co-authored-by: Copilot * fix mypy Co-authored-by: Copilot * Python: remove Foundry service session helpers Remove the public hosted-agent service session CRUD helpers from FoundryAgent and drop the related feature-stage inventory entry. Update the hosted-agent sample to create and delete service sessions directly through the preview AIProjectClient APIs, and tighten a few test harnesses surfaced by full workspace validation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix from merge * fix hosted env detection Co-authored-by: Copilot * reverted sample update * fix tests and code Co-authored-by: Copilot * remove aenter * skipping some tests Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/_telemetry.py | 11 +- .../core/agent_framework/foundry/__init__.py | 1 + .../tests/devui/test_ui_memory_regression.py | 8 +- .../agent_framework_foundry/__init__.py | 3 +- .../foundry/agent_framework_foundry/_agent.py | 243 ++++++++++++++++-- .../agent_framework_foundry/_chat_client.py | 6 +- .../tests/foundry/test_foundry_agent.py | 200 +++++++++++--- .../foundry/test_foundry_embedding_client.py | 1 + .../_responses.py | 58 ++--- .../foundry_hosting/tests/test_responses.py | 65 +++-- .../gemini/tests/test_gemini_client.py | 4 +- .../openai/test_openai_chat_client_azure.py | 2 + .../chat_client/built_in_chat_clients.py | 21 +- .../foundry-hosted-agents/responses/README.md | 2 +- .../responses/using_deployed_agent.py | 156 ++++++++--- 15 files changed, 601 insertions(+), 180 deletions(-) diff --git a/python/packages/core/agent_framework/_telemetry.py b/python/packages/core/agent_framework/_telemetry.py index f7ca2ce030..ec3d55be4b 100644 --- a/python/packages/core/agent_framework/_telemetry.py +++ b/python/packages/core/agent_framework/_telemetry.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import logging import os from typing import Any, Final @@ -60,13 +61,12 @@ def _detect_hosted_environment() -> None: global _hosted_env_detected if _hosted_env_detected: return - _hosted_env_detected = True - env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR) - if env_value is not None: + if (env_value := os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)) is not None: # Env var exists — trust its value and skip the fallback. if env_value: _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) + _hosted_env_detected = True return # Env var not set — fall back to AgentConfig as a second layer of defense. @@ -78,13 +78,12 @@ def _detect_hosted_environment() -> None: return except (ModuleNotFoundError, ValueError): return - try: + with contextlib.suppress(ImportError, AttributeError): from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports] if AgentConfig.from_env().is_hosted: _add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX) - except (ImportError, AttributeError): - pass + _hosted_env_detected = True def get_user_agent() -> str: diff --git a/python/packages/core/agent_framework/foundry/__init__.py b/python/packages/core/agent_framework/foundry/__init__.py index c1e47cd6b8..79736b5ca7 100644 --- a/python/packages/core/agent_framework/foundry/__init__.py +++ b/python/packages/core/agent_framework/foundry/__init__.py @@ -14,6 +14,7 @@ from typing import Any _IMPORTS: dict[str, tuple[str, str]] = { "AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"), "FoundryAgent": ("agent_framework_foundry", "agent-framework-foundry"), + "FoundryAgentOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatClient": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryChatOptions": ("agent_framework_foundry", "agent-framework-foundry"), "FoundryEmbeddingClient": ("agent_framework_foundry", "agent-framework-foundry"), diff --git a/python/packages/devui/tests/devui/test_ui_memory_regression.py b/python/packages/devui/tests/devui/test_ui_memory_regression.py index 7e5cd10e27..b042764f6c 100644 --- a/python/packages/devui/tests/devui/test_ui_memory_regression.py +++ b/python/packages/devui/tests/devui/test_ui_memory_regression.py @@ -655,7 +655,13 @@ async def test_devui_streaming_renderer_memory_is_bounded( ) try: - websocket_url = await _get_devtools_websocket_url(debug_port) + try: + websocket_url = await _get_devtools_websocket_url(debug_port) + except RuntimeError as exc: + return_code = browser_process.poll() + if return_code is not None: + pytest.skip(f"Chromium exited before DevTools became available (code {return_code}).") + pytest.skip(str(exc)) async with websocket_connect(websocket_url, max_size=None) as websocket: client = _CDPClient(websocket) diff --git a/python/packages/foundry/agent_framework_foundry/__init__.py b/python/packages/foundry/agent_framework_foundry/__init__.py index b70d1720f2..93953d667a 100644 --- a/python/packages/foundry/agent_framework_foundry/__init__.py +++ b/python/packages/foundry/agent_framework_foundry/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient +from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient from ._embedding_client import ( FoundryEmbeddingClient, @@ -25,6 +25,7 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "FoundryAgent", + "FoundryAgentOptions", "FoundryChatClient", "FoundryChatOptions", "FoundryEmbeddingClient", diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index b473c787e5..da64b65cd9 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast from agent_framework import ( AgentMiddlewareLayer, + AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, ContextProvider, @@ -52,11 +53,13 @@ else: if TYPE_CHECKING: from agent_framework import ( Agent, + AgentRunInputs, ChatAndFunctionMiddlewareTypes, ContextProvider, MiddlewareTypes, ToolTypes, ) + from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage] logger: logging.Logger = logging.getLogger("agent_framework.foundry") @@ -81,14 +84,54 @@ class FoundryAgentSettings(TypedDict, total=False): agent_version: str | None +class FoundryAgentOptions(OpenAIChatOptions, total=False): + """Microsoft Foundry agent-specific chat options. + + Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by + ``FoundryAgent`` / ``RawFoundryAgent``. + + Keyword Args: + extra_body: Additional request body values sent to the Responses API. + isolation_key: Isolation key used when lazily creating a hosted-agent + session through ``project_client.beta.agents.create_session(...)``. + """ + + extra_body: dict[str, Any] + isolation_key: str + + FoundryAgentOptionsT = TypeVar( "FoundryAgentOptionsT", bound=TypedDict, # type: ignore[valid-type] - default="OpenAIChatOptions", + default="FoundryAgentOptions", covariant=True, ) +def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Normalize and merge provider-specific extra_body values.""" + if extra_body is None: + merged: dict[str, Any] = {} + elif isinstance(extra_body, Mapping): + merged = dict(cast(Mapping[str, Any], extra_body)) + else: + raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.") + + if additions: + merged.update(additions) + return merged + + +def _uses_foundry_agent_session(conversation_id: Any) -> bool: + """Return whether a conversation_id should be treated as a Foundry agent session id.""" + return ( + isinstance(conversation_id, str) + and bool(conversation_id) + and not conversation_id.startswith("resp_") + and not conversation_id.startswith("conv_") + ) + + class RawFoundryAgentChatClient( # type: ignore[misc] RawOpenAIChatClient[FoundryAgentOptionsT], Generic[FoundryAgentOptionsT], @@ -167,13 +210,15 @@ class RawFoundryAgentChatClient( # type: ignore[misc] ) resolved_endpoint = settings.get("project_endpoint") - self.agent_name = settings.get("agent_name") - self.agent_version = settings.get("agent_version") + agent_name_setting = settings.get("agent_name") + self.agent_version: str | None = settings.get("agent_version") + self.allow_preview = allow_preview or False - if not self.agent_name: + if not agent_name_setting: raise ValueError( "Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable." ) + self.agent_name = agent_name_setting # Create or use provided project client self._should_close_client = False @@ -197,11 +242,13 @@ class RawFoundryAgentChatClient( # type: ignore[misc] self.project_client = AIProjectClient(**project_client_kwargs) self._should_close_client = True - # Get OpenAI client from project - async_client = self.project_client.get_openai_client() - + openai_client_kwargs: dict[str, Any] = {} + if default_headers: + openai_client_kwargs["default_headers"] = dict(default_headers) + if allow_preview: + openai_client_kwargs["agent_name"] = self.agent_name super().__init__( - async_client=async_client, + async_client=self.project_client.get_openai_client(**openai_client_kwargs), default_headers=default_headers, instruction_role=instruction_role, compaction_strategy=compaction_strategy, @@ -209,13 +256,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc] additional_properties=additional_properties, ) - def _get_agent_reference(self) -> dict[str, str]: - """Build the agent reference dict for the Responses API.""" - ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item] - if self.agent_version: - ref["version"] = self.agent_version - return ref - @override def as_agent( self, @@ -270,7 +310,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc] options: Mapping[str, Any], **kwargs: Any, ) -> dict[str, Any]: - """Prepare options for the Responses API, injecting agent reference and validating tools.""" + """Prepare options for the Responses API and validate client-side tools.""" # Validate tools — only FunctionTool allowed tools = options.get("tools", []) if tools: @@ -292,18 +332,58 @@ class RawFoundryAgentChatClient( # type: ignore[misc] if "input" in run_options and isinstance(run_options["input"], list): run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"])) - # Inject agent reference - run_options["extra_body"] = {"agent_reference": self._get_agent_reference()} + # Merge caller-supplied extra_body with any agent-specific request payload. + conversation_id = options.get("conversation_id") + extra_body = _merge_extra_body(run_options.pop("extra_body", None)) + if _uses_foundry_agent_session(conversation_id): + run_options.pop("previous_response_id", None) + run_options.pop("conversation", None) + extra_body["agent_session_id"] = conversation_id + if extra_body: + run_options["extra_body"] = extra_body + + run_options.pop("isolation_key", None) # Strip tools from request body - Foundry API rejects requests with both - # agent_reference and tools present. FunctionTools are invoked client-side + # agent endpoint and tools present. FunctionTools are invoked client-side # by the function invocation layer, not sent to the service. - run_options.pop("tools", None) - run_options.pop("tool_choice", None) - run_options.pop("parallel_tool_calls", None) + run_options.pop("model", None) + if not self.allow_preview: + run_options.pop("tools", None) + run_options.pop("tool_choice", None) + run_options.pop("parallel_tool_calls", None) return run_options + @override + def _parse_response_from_openai( + self, + response: Any, + options: dict[str, Any], + ) -> Any: + parsed_response = super()._parse_response_from_openai(response, options) + if _uses_foundry_agent_session(options.get("conversation_id")): + parsed_response.conversation_id = None + return parsed_response + + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, + ) -> Any: + parsed_chunk = super()._parse_chunk_from_openai( + event, + options, + function_call_ids, + seen_reasoning_delta_item_ids, + ) + if _uses_foundry_agent_session(options.get("conversation_id")): + parsed_chunk.conversation_id = None + return parsed_chunk + @override def _check_model_presence(self, options: dict[str, Any]) -> None: """Skip model check — model is configured on the Foundry agent.""" @@ -368,6 +448,26 @@ class RawFoundryAgentChatClient( # type: ignore[misc] return transformed + async def get_agent_version(self) -> str | None: + """Return the agent version if available, else None.""" + if self.agent_version is not None: + return self.agent_version + if not self.allow_preview: + return None + agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] + agent_name=self.agent_name + ) + versions_object = getattr(agent_details, "versions", None) + if not isinstance(versions_object, Mapping): + raise TypeError("Foundry agent details did not include a versions mapping.") + versions = cast(Mapping[str, Any], versions_object) + latest_version = versions.get("latest") + agent_version = getattr(cast(Any, latest_version), "version", None) + if not isinstance(agent_version, str): + raise TypeError("Foundry agent details did not include a latest version string.") + self.agent_version = agent_version + return agent_version + async def close(self) -> None: """Close the project client if we created it.""" if self._should_close_client: @@ -395,7 +495,7 @@ class _FoundryAgentChatClient( # type: ignore[misc] client = FoundryAgentClient( project_endpoint="https://your-project.services.ai.azure.com", agent_name="my-prompt-agent", - agent_version="1.0", + agent_version="1", credential=AzureCliCredential(), ) @@ -477,7 +577,7 @@ class RawFoundryAgent( # type: ignore[misc] agent = RawFoundryAgent( project_endpoint="https://your-project.services.ai.azure.com", agent_name="my-prompt-agent", - agent_version="1.0", + agent_version="1", credential=AzureCliCredential(), ) result = await agent.run("Hello!") @@ -570,7 +670,7 @@ class RawFoundryAgent( # type: ignore[misc] client=client, # type: ignore[arg-type] instructions=instructions, id=id, - name=name, + name=name or agent_name, description=description, tools=tools, # type: ignore[arg-type] default_options=cast(FoundryAgentOptionsT | None, default_options), @@ -582,6 +682,81 @@ class RawFoundryAgent( # type: ignore[misc] additional_properties=dict(additional_properties) if additional_properties is not None else None, ) + def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str: + """Resolve the isolation key from an explicit value or default_options.""" + resolved_isolation_key = ( + isolation_key if isolation_key is not None else self.default_options.get("isolation_key") + ) + if resolved_isolation_key is None: + raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].") + return resolved_isolation_key + + async def _create_service_session_id( + self, + *, + isolation_key: str | None = None, + ) -> str: + """Create a hosted Foundry service session and return the service session ID.""" + if not isinstance(self.client, RawFoundryAgentChatClient): + raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.") + if not self.client.allow_preview: + raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.") + + create_session_kwargs: dict[str, Any] = { + "agent_name": self.client.agent_name, + "isolation_key": self._resolve_service_session_isolation_key(isolation_key), + } + if version := await self.client.get_agent_version(): + from azure.ai.projects.models import VersionRefIndicator + + create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore + + service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs) + agent_session_id = getattr(service_session, "agent_session_id", None) + if not isinstance(agent_session_id, str) or not agent_session_id: + raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.") + + return agent_session_id + + @override + async def _prepare_run_context( + self, + *, + messages: AgentRunInputs | None, + session: AgentSession | None, + tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None, + options: Mapping[str, Any] | None, + compaction_strategy: CompactionStrategy | None, + tokenizer: TokenizerProtocol | None, + function_invocation_kwargs: Mapping[str, Any] | None, + client_kwargs: Mapping[str, Any] | None, + ) -> _RunContext: + runtime_options = dict(options) if options else {} + effective_options = { + **{key: value for key, value in self.default_options.items() if value is not None}, + **{key: value for key, value in runtime_options.items() if value is not None}, + } + + if ( + session is not None + and session.service_session_id is None + and effective_options.get("isolation_key") is not None + ): + session.service_session_id = await self._create_service_session_id( + isolation_key=cast(str | None, effective_options.get("isolation_key")), + ) + + return await super()._prepare_run_context( + messages=messages, + session=session, + tools=tools, + options=runtime_options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + ) + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, @@ -708,6 +883,19 @@ class FoundryAgent( # type: ignore[misc] ) -> None: """Initialize a Foundry Agent with full middleware and telemetry. + ``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents + typically provide ``agent_version`` directly. HostedAgents can omit + ``agent_version`` and, when they need preview-only session APIs, should + opt in with ``allow_preview=True`` when this class creates the underlying + ``AIProjectClient``. If you pass ``project_client`` explicitly, it must + already be configured for preview APIs before being passed to + ``FoundryAgent``. + + To lazily create HostedAgent service sessions inside the agent, pass an + ``isolation_key`` through ``default_options`` (or per-run options). The + agent stores the resulting HostedAgent session ID in + ``AgentSession.service_session_id`` and reuses it on subsequent runs. + Keyword Args: project_endpoint: The Foundry project endpoint URL. agent_name: The name of the Foundry agent to connect to. @@ -715,6 +903,9 @@ class FoundryAgent( # type: ignore[misc] credential: Azure credential for authentication. project_client: An existing AIProjectClient to use. allow_preview: Enables preview opt-in on internally-created AIProjectClient. + Set this to ``True`` for HostedAgents that need preview-only + session APIs, including lazy service session creation from + ``isolation_key``. tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted. context_providers: Optional context providers. middleware: Optional agent-level middleware. @@ -726,6 +917,8 @@ class FoundryAgent( # type: ignore[misc] description: Optional local description for the local agent wrapper. instructions: Optional instructions for the local agent wrapper. default_options: Default chat options for the local agent wrapper. + ``FoundryAgentOptions`` can include ``isolation_key`` and + ``extra_body`` when working with HostedAgents. require_per_service_call_history_persistence: Whether to require per-service-call chat history persistence when using local history providers. function_invocation_configuration: Optional function invocation configuration override. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 4428d69dc6..57522fb886 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -204,9 +204,13 @@ class RawFoundryChatClient( # type: ignore[misc] project_client_kwargs["allow_preview"] = allow_preview project_client = AIProjectClient(**project_client_kwargs) + openai_kwargs: dict[str, Any] = {} + if default_headers: + openai_kwargs["default_headers"] = default_headers + super().__init__( model=resolved_model, - async_client=project_client.get_openai_client(), + async_client=project_client.get_openai_client(**openai_kwargs), default_headers=default_headers, instruction_role=instruction_role, compaction_strategy=compaction_strategy, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 829af6ab87..73670d0bbc 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -5,11 +5,12 @@ from __future__ import annotations import inspect import os import sys +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool +from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -54,7 +55,7 @@ def test_raw_foundry_agent_chat_client_init_requires_agent_name() -> None: def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: - """Test construction with agent_name and project_client.""" + """Test construction with agent_name and project_client without preview agent binding.""" mock_project = MagicMock() mock_project.get_openai_client.return_value = MagicMock() @@ -67,6 +68,27 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None: assert client.agent_name == "test-agent" assert client.agent_version == "1.0" + mock_project.get_openai_client.assert_called_once_with() + + +def test_raw_foundry_agent_chat_client_init_passes_agent_name_when_preview_enabled() -> None: + """Test preview-enabled clients bind the OpenAI client to the agent endpoint.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="hosted-agent", + allow_preview=True, + default_headers={"x-test": "1"}, + ) + + assert client.agent_name == "hosted-agent" + mock_project.get_openai_client.assert_called_once_with( + agent_name="hosted-agent", + default_headers={"x-test": "1"}, + ) def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: @@ -80,38 +102,6 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None: assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) -def test_raw_foundry_agent_chat_client_get_agent_reference_with_version() -> None: - """Test agent reference includes version when provided.""" - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="my-agent", - agent_version="2.0", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "my-agent", "version": "2.0", "type": "agent_reference"} - - -def test_raw_foundry_agent_chat_client_get_agent_reference_without_version() -> None: - """Test agent reference omits version for HostedAgents.""" - - mock_project = MagicMock() - mock_project.get_openai_client.return_value = MagicMock() - - client = RawFoundryAgentChatClient( - project_client=mock_project, - agent_name="hosted-agent", - ) - - ref = client._get_agent_reference() - assert ref == {"name": "hosted-agent", "type": "agent_reference"} - assert "version" not in ref - - def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None: """Test that as_agent() wraps the client in FoundryAgent using the same client class.""" @@ -196,12 +186,11 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to options={"tools": [my_func]}, ) - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + assert result == {} -async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> None: - """Test that _prepare_options strips tools, tool_choice, and parallel_tool_calls from run_options.""" +async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None: + """Test that _prepare_options strips model and tool-loop fields from run_options.""" mock_project = MagicMock() mock_openai = MagicMock() @@ -222,6 +211,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", new_callable=AsyncMock, return_value={ + "model": "gpt-4.1", "tools": [{"type": "function", "function": {"name": "my_func"}}], "tool_choice": "auto", "parallel_tool_calls": True, @@ -232,11 +222,69 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_tools() -> N options={"tools": [my_func]}, ) + assert "model" not in result assert "tools" not in result assert "tool_choice" not in result assert "parallel_tool_calls" not in result - assert "extra_body" in result - assert result["extra_body"]["agent_reference"]["name"] == "test-agent" + assert result == {} + + +async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None: + """Test that service_session_id is forwarded as agent_session_id for hosted sessions.""" + + mock_project = MagicMock() + mock_openai = MagicMock() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options", + new_callable=AsyncMock, + return_value={ + "extra_body": {"custom": "value"}, + "previous_response_id": "should-be-removed", + }, + ): + result = await client._prepare_options( + messages=[Message(role="user", contents="hi")], + options={"conversation_id": "agent-session-123", "isolation_key": "iso-key"}, + ) + + assert result["extra_body"] == { + "custom": "value", + "agent_session_id": "agent-session-123", + } + assert "previous_response_id" not in result + assert "conversation" not in result + assert "isolation_key" not in result + + +def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id_for_agent_sessions() -> None: + """Test that agent-session continuations do not overwrite session.service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + parsed = ChatResponse(conversation_id="resp_123") + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_response_from_openai", + return_value=parsed, + ): + result = client._parse_response_from_openai( + response=MagicMock(), + options={"conversation_id": "agent-session-123"}, + ) + + assert result.conversation_id is None def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: @@ -366,6 +414,74 @@ def test_raw_foundry_agent_init_with_function_tools() -> None: assert agent.default_options.get("tools") is not None +async def test_raw_foundry_agent_prepare_run_context_creates_service_session_from_isolation_key() -> None: + """Test that RawFoundryAgent lazily creates a hosted session and stores it on service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + mock_project.beta = SimpleNamespace( + agents=SimpleNamespace( + create_session=AsyncMock(return_value=SimpleNamespace(agent_session_id="agent-session-123")) + ) + ) + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + agent_version="1.0", + allow_preview=True, + ) + session = AgentSession() + + with patch( + "agent_framework._agents.RawAgent._prepare_run_context", + new=AsyncMock(return_value={"ok": True}), + ) as mock_prepare_run_context: + result = await agent._prepare_run_context( + messages="hi", + session=session, + tools=None, + options={"isolation_key": "iso-key"}, + compaction_strategy=None, + tokenizer=None, + function_invocation_kwargs=None, + client_kwargs=None, + ) + + assert result == {"ok": True} + assert session.service_session_id == "agent-session-123" + mock_project.beta.agents.create_session.assert_awaited_once() + create_session_kwargs = mock_project.beta.agents.create_session.await_args.kwargs + assert create_session_kwargs["agent_name"] == "test-agent" + assert create_session_kwargs["isolation_key"] == "iso-key" + assert "version_indicator" in create_session_kwargs + mock_prepare_run_context.assert_awaited_once() + + +async def test_raw_foundry_agent_prepare_run_context_requires_preview_for_hosted_sessions() -> None: + """Test that hosted-agent sessions require allow_preview=True.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + agent = RawFoundryAgent( + project_client=mock_project, + agent_name="test-agent", + ) + + with pytest.raises(RuntimeError, match="allow_preview=True"): + await agent._prepare_run_context( + messages="hi", + session=AgentSession(), + tools=None, + options={"isolation_key": "iso-key"}, + compaction_strategy=None, + tokenizer=None, + function_invocation_kwargs=None, + client_kwargs=None, + ) + + def test_foundry_agent_init() -> None: """Test construction of the full-middleware agent.""" @@ -483,9 +599,10 @@ async def test_foundry_agent_configure_azure_monitor_import_error() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_agent_integration_tests_disabled +@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.") async def test_foundry_agent_basic_run() -> None: """Smoke-test FoundryAgent against a real configured agent.""" - async with FoundryAgent(credential=AzureCliCredential()) as agent: + async with FoundryAgent(credential=AzureCliCredential(), allow_preview=True) as agent: response = await agent.run("Please respond with exactly: 'This is a response test.'") assert isinstance(response, AgentResponse) @@ -496,6 +613,7 @@ async def test_foundry_agent_basic_run() -> None: @pytest.mark.flaky @pytest.mark.integration @skip_if_foundry_agent_integration_tests_disabled +@pytest.mark.skip(reason="Test agent seems to have disappeared from the test environment; needs investigation.") async def test_foundry_agent_custom_client_run() -> None: """Smoke-test FoundryAgent against a real configured agent.""" async with FoundryAgent(credential=AzureCliCredential(), client_type=RawFoundryAgentChatClient) as agent: diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index e9e342d675..664123637d 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -198,6 +198,7 @@ class TestRawFoundryEmbeddingClient: "FOUNDRY_MODELS_API_KEY": "env-key", "FOUNDRY_EMBEDDING_MODEL": "env-model", }, + clear=True, ), patch("agent_framework_foundry._embedding_client.EmbeddingsClient"), patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"), diff --git a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py index cac0ac3790..9078c59d22 100644 --- a/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py +++ b/python/packages/foundry_hosting/agent_framework_foundry_hosting/_responses.py @@ -172,12 +172,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): self._agent = agent self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType] - @staticmethod - def _is_streaming_request(request: CreateResponse) -> bool: - """Check if the request is a streaming request.""" - return request.stream is not None and request.stream is True - - def _handle_response( + async def _handle_response( self, request: CreateResponse, context: ResponseContext, @@ -186,11 +181,10 @@ class ResponsesHostServer(ResponsesAgentServerHost): """Handle the creation of a response.""" if self._is_workflow_agent: # Workflow agents are handled differently because they require checkpoint restoration - return self._handle_workflow_agent(request, context) + return self._handle_inner_workflow(request, context) + return self._handle_inner_agent(request, context) - return self._handle_regular_agent(request, context) - - async def _handle_regular_agent( + async def _handle_inner_agent( self, request: CreateResponse, context: ResponseContext, @@ -200,25 +194,24 @@ class ResponsesHostServer(ResponsesAgentServerHost): input_messages = _items_to_messages(input_items) history = await context.get_history() - messages: list[str | Content | Message] = [*_output_items_to_messages(history), *input_messages] + run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]} + is_streaming_request = request.stream is not None and request.stream is True chat_options, are_options_set = _to_chat_options(request) - is_streaming_request = self._is_streaming_request(request) response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) yield response_event_stream.emit_created() yield response_event_stream.emit_in_progress() + if are_options_set and not isinstance(self._agent, RawAgent): + logger.warning("Agent doesn't support runtime options. They will be ignored.") + else: + run_kwargs["options"] = chat_options + if not is_streaming_request: # Run the agent in non-streaming mode - if isinstance(self._agent, RawAgent): - raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] - response = await raw_agent.run(messages, stream=False, options=chat_options) - else: - if are_options_set: - logger.warning("Agent doesn't support runtime options. They will be ignored.") - response = await self._agent.run(messages, stream=False) + response = await self._agent.run(stream=False, **run_kwargs) # type: ignore[reportUnknownMemberType] for message in response.messages: for content in message.contents: @@ -228,20 +221,12 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() return - # Run the agent in streaming mode - if isinstance(self._agent, RawAgent): - raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType] - response_stream = raw_agent.run(messages, stream=True, options=chat_options) - else: - if are_options_set: - logger.warning("Agent doesn't support runtime options. They will be ignored.") - response_stream = self._agent.run(messages, stream=True) - # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. tracker = _OutputItemTracker(response_event_stream) - async for update in response_stream: + # Run the agent in streaming mode + async for update in self._agent.run(stream=True, **run_kwargs): # type: ignore[reportUnknownMemberType] for content in update.contents: for event in tracker.handle(content): yield event @@ -256,7 +241,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() - async def _handle_workflow_agent( + async def _handle_inner_workflow( self, request: CreateResponse, context: ResponseContext, @@ -269,8 +254,7 @@ class ResponsesHostServer(ResponsesAgentServerHost): """ input_items = await context.get_input_items() input_messages = _items_to_messages(input_items) - - is_streaming_request = self._is_streaming_request(request) + is_streaming_request = request.stream is not None and request.stream is True _, are_options_set = _to_chat_options(request) if are_options_set: @@ -311,7 +295,8 @@ class ResponsesHostServer(ResponsesAgentServerHost): response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model) # Create a new checkpoint storage for this response based on the following rules: - # - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response + # - If no previous response ID or conversation ID is provided, + # create a new checkpoint storage for this response # - If a previous response ID is provided, create a new checkpoint storage for this response # - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation context_id = context.conversation_id or context.response_id @@ -333,14 +318,12 @@ class ResponsesHostServer(ResponsesAgentServerHost): yield response_event_stream.emit_completed() return - # Run the agent in streaming mode - response_stream = self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage) - # Track the current active output item builder for streaming; # lazily created on matching content, closed when a different type arrives. tracker = _OutputItemTracker(response_event_stream) - async for update in response_stream: + # Run the workflow agent in streaming mode + async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage): for content in update.contents: for event in tracker.handle(content): yield event @@ -355,7 +338,6 @@ class ResponsesHostServer(ResponsesAgentServerHost): await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name) yield response_event_stream.emit_completed() - return @staticmethod async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None: diff --git a/python/packages/foundry_hosting/tests/test_responses.py b/python/packages/foundry_hosting/tests/test_responses.py index 13538b6c9a..237a3c7634 100644 --- a/python/packages/foundry_hosting/tests/test_responses.py +++ b/python/packages/foundry_hosting/tests/test_responses.py @@ -41,9 +41,10 @@ def _make_agent( *, response: AgentResponse | None = None, stream_updates: list[AgentResponseUpdate] | None = None, + raw_agent: bool = True, ) -> MagicMock: """Create a mock agent implementing SupportsAgentRun.""" - agent = MagicMock(spec=RawAgent) + agent = MagicMock(spec=RawAgent) if raw_agent else MagicMock() agent.id = "test-agent" agent.name = "Test Agent" agent.description = "A mock agent for testing" @@ -267,10 +268,18 @@ class TestNonStreaming: async def test_chat_options_forwarded(self) -> None: agent = _make_agent( - response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]) + response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]), + raw_agent=True, ) server = _make_server(agent) - resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024) + resp = await _post( + server, + stream=False, + temperature=0.5, + top_p=0.9, + max_output_tokens=1024, + parallel_tool_calls=True, + ) assert resp.status_code == 200 agent.run.assert_awaited_once() @@ -280,6 +289,7 @@ class TestNonStreaming: assert options["temperature"] == 0.5 assert options["top_p"] == 0.9 assert options["max_tokens"] == 1024 + assert options["allow_multiple_tool_calls"] is True # endregion @@ -289,6 +299,31 @@ class TestNonStreaming: class TestStreaming: + async def test_chat_options_forwarded(self) -> None: + agent = _make_agent( + stream_updates=[AgentResponseUpdate(contents=[Content.from_text("ok")], role="assistant")], + raw_agent=True, + ) + server = _make_server(agent) + resp = await _post( + server, + stream=True, + temperature=0.5, + top_p=0.9, + max_output_tokens=1024, + parallel_tool_calls=True, + ) + + assert resp.status_code == 200 + agent.run.assert_called_once() + call_kwargs = agent.run.call_args.kwargs + assert call_kwargs["stream"] is True + options = call_kwargs["options"] + assert options["temperature"] == 0.5 + assert options["top_p"] == 0.9 + assert options["max_tokens"] == 1024 + assert options["allow_multiple_tool_calls"] is True + async def test_basic_text_streaming(self) -> None: agent = _make_agent( stream_updates=[ @@ -1426,7 +1461,7 @@ class TestMultiTurnMixedContent: assert body["status"] == "completed" # Verify agent received text + image - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert messages[0].role == "user" assert len(messages[0].contents) == 2 @@ -1464,7 +1499,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1501,7 +1536,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1542,7 +1577,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 3 assert messages[0].role == "user" assert messages[0].contents[0].type == "text" @@ -1591,7 +1626,7 @@ class TestMultiTurnMixedContent: assert body2["status"] == "completed" # Verify second call receives history from turn 1 + text+image input - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] # History: output message from turn 1 ("Send me an image") # Input: message with text + image assert len(second_call_messages) >= 2 @@ -1652,7 +1687,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history including function call/result - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] roles = [m.role for m in second_call_messages] assert "assistant" in roles assert "tool" in roles @@ -1703,7 +1738,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify history includes the reasoning and text from turn 1 - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] assert len(second_call_messages) >= 2 # history + new input async def test_multi_turn_with_mixed_content_and_streaming(self) -> None: @@ -1795,7 +1830,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 2 assert messages[0].role == "user" assert messages[0].contents[0].type == "text" @@ -1867,7 +1902,7 @@ class TestMultiTurnMixedContent: assert resp3.json()["status"] == "completed" # Verify turn 3 received full history from turns 1+2 plus new image input - third_call_messages = agent.run.call_args_list[2].args[0] + third_call_messages = agent.run.call_args_list[2].kwargs["messages"] # Should have: history from turn 1 (assistant text) + history from turn 2 # (function_call, function_call_output, text) + new input (text + image) assert len(third_call_messages) >= 5 @@ -1918,7 +1953,7 @@ class TestMultiTurnMixedContent: body = resp.json() assert body["status"] == "completed" - messages = agent.run.call_args.args[0] + messages = agent.run.call_args.kwargs["messages"] assert len(messages) == 1 assert len(messages[0].contents) == 2 assert messages[0].contents[0].type == "text" @@ -1982,7 +2017,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history from turn 1 + new text+file input - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] assert len(second_call_messages) >= 2 # History should include the assistant response from turn 1 @@ -2050,7 +2085,7 @@ class TestMultiTurnMixedContent: assert resp2.json()["status"] == "completed" # Verify turn 2 received history with function call + new text+image - second_call_messages = agent.run.call_args_list[1].args[0] + second_call_messages = agent.run.call_args_list[1].kwargs["messages"] # History should contain function_call and function_result from turn 1 fc_contents = [ c for m in second_call_messages if m.role == "assistant" for c in m.contents if c.type == "function_call" diff --git a/python/packages/gemini/tests/test_gemini_client.py b/python/packages/gemini/tests/test_gemini_client.py index d5fcf5dbe0..480525ea1d 100644 --- a/python/packages/gemini/tests/test_gemini_client.py +++ b/python/packages/gemini/tests/test_gemini_client.py @@ -285,8 +285,10 @@ def test_vertex_ai_requires_project_and_location_together(monkeypatch: pytest.Mo GeminiChatClient(model="gemini-2.5-flash") -async def test_missing_model_raises_on_get_response() -> None: +async def test_missing_model_raises_on_get_response(monkeypatch: pytest.MonkeyPatch) -> None: """Raises ValueError at call time when no model is set on the client or in options.""" + monkeypatch.delenv("GEMINI_MODEL", raising=False) + monkeypatch.delenv("GOOGLE_MODEL", raising=False) client, mock = _make_gemini_client(model=None) # type: ignore[arg-type] mock.aio.models.generate_content = AsyncMock() diff --git a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py index b16fbd0f7f..a5fdff72b5 100644 --- a/python/packages/openai/tests/openai/test_openai_chat_client_azure.py +++ b/python/packages/openai/tests/openai/test_openai_chat_client_azure.py @@ -355,6 +355,7 @@ async def test_integration_web_search() -> None: @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled @_with_azure_openai_debug() +@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.") async def test_integration_client_file_search() -> None: async with AzureCliCredential() as credential: client = OpenAIChatClient(credential=credential) @@ -380,6 +381,7 @@ async def test_integration_client_file_search() -> None: @pytest.mark.integration @skip_if_azure_openai_integration_tests_disabled @_with_azure_openai_debug() +@pytest.mark.skip(reason="Azure OpenAI with files raises 500 error. Needs investigation.") async def test_integration_client_file_search_streaming() -> None: async with AzureCliCredential() as credential: client = OpenAIChatClient(credential=credential) diff --git a/python/samples/02-agents/chat_client/built_in_chat_clients.py b/python/samples/02-agents/chat_client/built_in_chat_clients.py index 4d79cc17b4..32f3efcf57 100644 --- a/python/samples/02-agents/chat_client/built_in_chat_clients.py +++ b/python/samples/02-agents/chat_client/built_in_chat_clients.py @@ -75,11 +75,7 @@ def get_client(client_name: ClientName) -> SupportsChatGetResponse[Any]: if client_name == "azure_openai_chat_completion": return OpenAIChatCompletionClient(credential=AzureCliCredential()) if client_name == "foundry_chat": - return FoundryChatClient( - project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], - model=os.environ["FOUNDRY_MODEL"], - credential=AzureCliCredential(), - ) + return FoundryChatClient(credential=AzureCliCredential()) raise ValueError(f"Unsupported client name: {client_name}") @@ -93,21 +89,6 @@ async def main(client_name: ClientName = "openai_chat") -> None: print(f"Client: {client_name}") print(f"User: {message.text}") - if isinstance(client, FoundryChatClient): - async with client: - if stream: - response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) - print("Assistant: ", end="") - async for chunk in response_stream: - if chunk.text: - print(chunk.text, end="") - print("") - else: - print( - f"Assistant: {await client.get_response([message], stream=False, options={'tools': get_weather})}" - ) - return - if stream: response_stream = client.get_response([message], stream=True, options={"tools": get_weather}) print("Assistant: ", end="") diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/README.md b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md index 072dbea36f..3181cb5ea4 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/README.md +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/README.md @@ -8,4 +8,4 @@ This folder contains a list of samples that show how to host agents using the `r | [02_local_tools](./02_local_tools) | An example of hosting an agent with the `responses` API and local tools including a function tool and a local shell tool. | | [03_remote_mcp](./03_remote_mcp) | An example of hosting an agent with the `responses` API and remote MCPs, including a GitHub MCP server and a Foundry Toolbox. | | [04_workflows](./04_workflows) | An example of hosting a workflow with the `responses` API. | -| [using_deployed_agent.py](./using_deployed_agent.py) | An example of how to use the deployed agent in Agent Framework. | +| [using_deployed_agent.py](./using_deployed_agent.py) | Connect to the deployed basic Foundry agent with `FoundryAgent`, `allow_preview=True`, and version `v2`. | diff --git a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py index 1f3525775a..9d1d50b959 100644 --- a/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py +++ b/python/samples/04-hosting/foundry-hosted-agents/responses/using_deployed_agent.py @@ -1,50 +1,146 @@ # Copyright (c) Microsoft. All rights reserved. +from __future__ import annotations + import asyncio +import os +from collections.abc import Mapping +from typing import Any, cast -from agent_framework import Agent, AgentResponse, AgentResponseUpdate, ResponseStream -from agent_framework.openai import OpenAIChatClient -from typing_extensions import Any +from agent_framework import AgentSession +from agent_framework.foundry import FoundryAgent +from azure.ai.projects.aio import AIProjectClient +from azure.ai.projects.models import VersionRefIndicator +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +load_dotenv() """ -This script demonstrates how to talk to a deployed agent using the OpenAIChatClient. +This sample demonstrates how to connect to the deployed basic Foundry agent with +`FoundryAgent`. + +The sample uses environment variables for configuration, which can be set in a .env file or in the environment directly: +Environment variables: + FOUNDRY_PROJECT_ENDPOINT: Azure AI Foundry project endpoint. + FOUNDRY_AGENT_NAME: Hosted agent name. + FOUNDRY_AGENT_VERSION: Hosted agent version. Optional, defaults to latest if not specified. + +After you deploy one of the agents in this directory, you can run this sample +to connect to it and have a conversation. + +Note: The `allow_preview=True` flag is required to connect to the new hosted +agents, as this is a preview feature in Foundry. -Depending on where you have deployed your agent (local or Foundry Hosting), you may -need to change the base_url when initializing the OpenAIChatClient. """ -async def print_streaming_response(streaming_response: ResponseStream[AgentResponseUpdate, AgentResponse[Any]]) -> None: - async for chunk in streaming_response: - if chunk.text: - print(chunk.text, end="", flush=True) +async def create_hosted_agent_session( + *, + agent: FoundryAgent, + project_client: AIProjectClient, + agent_name: str, + agent_version: str | None, + isolation_key: str, +) -> AgentSession: + """Create a hosted-agent service session and wrap it in an AgentSession.""" + create_session_kwargs: dict[str, Any] = { + "agent_name": agent_name, + "isolation_key": isolation_key, + } + resolved_agent_version = agent_version + if resolved_agent_version is None: + agent_details = await cast(Any, project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType] + agent_name=agent_name + ) + versions = getattr(agent_details, "versions", None) + if not isinstance(versions, Mapping): + raise ValueError("Hosted agent details did not include a versions mapping.") + latest_version = getattr(cast(Any, versions.get("latest")), "version", None) + if not isinstance(latest_version, str) or not latest_version: + raise ValueError("Hosted agent details did not include a latest version string.") + resolved_agent_version = latest_version + + create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=resolved_agent_version) + service_session = await project_client.beta.agents.create_session(**create_session_kwargs) + agent_session_id = getattr(service_session, "agent_session_id", None) + if not isinstance(agent_session_id, str) or not agent_session_id: + raise ValueError("Hosted agent session creation did not return a non-empty agent_session_id.") + + return agent.get_session(agent_session_id) async def main() -> None: - agent = Agent(client=OpenAIChatClient(base_url="http://localhost:8088")) - session = agent.create_session() + credential = AzureCliCredential() + project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"] + agent_name = os.environ["FOUNDRY_AGENT_NAME"] + agent_version = os.getenv("FOUNDRY_AGENT_VERSION") + isolation_key = "my-isolation-key" - # First turn - query = "Hi!" - print(f"User: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + project_client = AIProjectClient( + endpoint=project_endpoint, + credential=credential, + allow_preview=True, + ) + async with ( + project_client, + FoundryAgent( + project_client=project_client, + agent_name=agent_name, + agent_version=agent_version, + allow_preview=True, + ) as agent, + ): + session = await create_hosted_agent_session( + agent=agent, + project_client=project_client, + agent_name=agent_name, + agent_version=agent_version, + isolation_key=isolation_key, + ) - # Second turn - query = "Your name is Javis. What can you do?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + try: + # 1. Send the first turn. + query = "Hi!" + print(f"User: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) - # Third turn - query = "What is your name?" - print(f"\nUser: {query}") - print("Agent: ", end="", flush=True) - streaming_response = agent.run(query, session=session, stream=True) - await print_streaming_response(streaming_response) + # 2. Continue the conversation with the same deployed agent session. + query = "Your name is Javis. What can you do?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + + # 3. Ask a follow-up question in the same session. + query = "What is your name?" + print(f"\nUser: {query}") + print("Agent: ", end="", flush=True) + async for chunk in agent.run(query, session=session, stream=True): + if chunk.text: + print(chunk.text, end="", flush=True) + finally: + if session.service_session_id is not None: + await project_client.beta.agents.delete_session( + agent_name=agent_name, + session_id=session.service_session_id, + isolation_key=isolation_key, + ) if __name__ == "__main__": asyncio.run(main()) + +""" +Sample output: +User: Hi! +Agent: Hello! How can I help you today? +User: Your name is Javis. What can you do? +Agent: I can answer questions and help with tasks using the instructions configured on the deployed agent. +User: What is your name? +Agent: My name is Javis. +""" From da32e8cf80a8e90232fcd7a519b5f66997a33275 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 18:41:20 +0900 Subject: [PATCH 50/52] Python: (core): Add functional workflow API (#4238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add functional workflow api * cleanup * More cleanup * address copilot feedback * Address PR feedbacK * updates * PR feedback * Address review comments on functional workflow samples - Swap 05/06 get-started samples: agent workflow first (motivates why workflows exist), simple text workflow second - Rename text_pipeline → text_workflow, poem_pipeline → poem_workflow - Add @step to agent workflow sample (05) to demonstrate caching - Switch agent samples to AzureOpenAIResponsesClient with Foundry - Remove .as_agent() from agent_integration.py to focus on the key difference between inline agent calls vs @step-cached calls - Add commented-out Agent.run example in hitl_review.py - Add clarifying comment in _functional.py that event streaming is buffered (not true per-token streaming) - Add naive_group_chat.py functional sample: round-robin group chat as a plain Python loop - Update READMEs to reflect new file names and group chat sample Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix pyright type errors * Address PR review comments on functional workflow API 1. Allow request_info inside @step: Auto-inject RunContext into step functions that declare a RunContext parameter (by type or name 'ctx'), and expose get_run_context() for programmatic access. 2. Handle None responses: Log a warning when a response value is None, and document the behavior in request_info docstring. 3. Add executor_bypassed event type: Replace executor_invoked + executor_completed with a single executor_bypassed event when a step replays from cache, making cached vs live execution explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add regression tests for PR review comments on functional workflow API The three review comments (request_info in @step, None response handling, executor_bypassed event type) were already addressed in 7da7db4e. This commit adds cross-cutting regression tests that exercise the interactions between these features: - HITL in step with caching: preceding step bypassed on resume - Full checkpoint lifecycle with HITL step (interrupt -> resume -> restore) - None response inside step-level request_info logs warning - WorkflowInterrupted from step does not emit executor_failed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR #4238 review comments on functional workflow API Comment 1 (request_info in @step): Already supported. Added comment in StepWrapper.__call__ explaining why WorkflowInterrupted (BaseException) safely bypasses the except Exception handler. Comment 2 (None response): Added docstring to _get_response clarifying the (found, value) return tuple semantics and None handling. Comment 3 (bypass event type): executor_bypassed is already a dedicated event type in WorkflowEventType. Updated comment at the bypass site to make the deliberate event type choice explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add experimental API warnings to functional workflow module Mark all public classes and decorators (workflow, step, RunContext, FunctionalWorkflow, StepWrapper, FunctionalWorkflowAgent) as experimental and subject to change or removal. * Address PR #4238 review comments from @eavanvalkenburg - RunContext docstring leads with purpose (opt-in handle for HITL, custom events, state) so readers importing it from the public surface understand its role before the mechanics (#2993513452). - Rename `06_first_functional_workflow.py` to `06_functional_workflow_basics.py`; the previous filename was confusing since it followed `05_functional_workflow_with_agents.py` (#2993531979). - Simplify `05_functional_workflow_with_agents.py` to call agents directly without a @step wrapper; the step-vs-no-step contrast lives in `03-workflows/functional/agent_integration.py`, keeping the get-started sample minimal (#2993525532). - Switch functional samples to `FoundryChatClient` for consistency with the rest of 01-get-started and 03-workflows (follow-up on #2876988570). - Use walrus in `hitl_review.py` final-state assertion (#2993572182). - Add expected-output block to `basic_streaming_pipeline.py` (#2993557609). - Clarify in `parallel_pipeline.py` that `@step` composes with `asyncio.gather` (#2993597282). - `naive_group_chat.py` threads `list[Message]` between turns instead of stringifying the transcript, preserving role/authorship (#2993583231). Drive-by: pre-commit hook sorts an unrelated import block in `samples/04-hosting/foundry-hosted-agents/responses/02_local_tools/main.py`. * Fix 10 functional-workflow API bugs from /ultrareview pass - bug_001: `ctx.request_info()` without an explicit `request_id` now derives a deterministic `auto::` id from the call-counter, so HITL resume works correctly on the documented default path. A uuid was regenerated on every replay, making resume impossible. - bug_002: `StepWrapper.__call__` no longer deepcopies arguments on the cache-hit replay branch. The copy is only performed on the live-execution path (for the event log) and falls back to the original mapping if deepcopy fails, so steps whose args aren't deepcopyable (locks, sockets, sessions) can still resume from checkpoint. - bug_007: `_set_responses` now prunes each resolved `request_id` from `_pending_requests`, and the cache-hit branch in `request_info` does the same. Previously, answered requests were re-serialized into every subsequent checkpoint and the final checkpoint falsely claimed pending requests even after the workflow completed. - bug_008: `_compute_signature_hash` now mixes the function's `co_code` and `co_names` into the checkpoint signature, so changes to the workflow body invalidate older checkpoints even when steps are accessed via module / class attributes (which `_discover_step_names` can't see statically). `RunContext._record_observed_step` records observed step names for diagnostics. - bug_010: `FunctionalWorkflow.run()` docstring corrected — says "at least one of message/responses/checkpoint_id" and explicitly notes `responses` may be combined with `checkpoint_id` (the validator already allowed this). - bug_013: `FunctionalWorkflowAgent` now surfaces `request_info` events as `FunctionApprovalRequestContent` items (mirroring graph `WorkflowAgent`), threads `responses=` and `checkpoint_id=` through to the underlying workflow, and exposes `pending_requests`. Previously `.as_agent()` returned empty `AgentResponse` for HITL workflows — effectively unusable. - bug_014: `FunctionalWorkflow` now clears `_last_message`, `_last_step_cache`, and `_last_pending_request_ids` on clean completion. `run()` validates that `responses=` keys intersect the currently-pending request set (or raises with a clear error) instead of silently replaying against stale singleton state from a prior run. - bug_015: `FunctionalWorkflow.as_agent` signature now matches graph `Workflow.as_agent`: accepts `name`, `description`, `context_providers`, and `**kwargs`. `FunctionalWorkflowAgent` stores the overrides. - bug_017: `RunContext.set_state` raises `ValueError` for underscore- prefixed keys (the framework's `_step_cache` / `_original_message` keys would silently clobber user state on checkpoint save and user underscore-prefixed state was dropped on restore). Docstring documents the reserved prefix. - merged_bug_003: Workflow function arity is validated at decoration time. Multiple non-ctx parameters raise `ValueError` immediately (previously every arg past the first was silently dropped at call time). Passing a non-None `message` to a ctx-only workflow raises `ValueError` instead of silently discarding the message. Test coverage: +18 regression tests covering every fix. Full workflow suite now 766 passed, 1 skipped, 2 xfailed; full core suite 2338 passed. * Deslop functional.py fix commit - Remove dead instrumentation added in the prior commit that was never consumed: `RunContext._observed_step_names`, `RunContext._record_observed_step`, `FunctionalWorkflow._runtime_step_names`, and `FunctionalWorkflowAgent._extra_kwargs`. The signature hash relies on `co_code` alone, which covers the attribute-access case without the collection-scaffolding. - Trim over-explanatory comments that restated what the code does or what it no longer does. Keep only the comments that answer "why" for the non-obvious bits (deterministic id contract, defensive deepcopy, stale replay guard). - Compress the `_compute_signature_hash` and FunctionalWorkflow `__init__` block docstrings without losing the user-facing reasoning. Net -49 lines. Regression lock preserved (766 passed, 1 skipped, 2 xfailed). * Fix functional workflow review feedback --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- .../packages/core/agent_framework/__init__.py | 16 + .../core/agent_framework/_feature_stage.py | 1 + .../agent_framework/_workflows/_events.py | 7 + .../agent_framework/_workflows/_functional.py | 1551 +++++++++++++++ .../agent_framework/_workflows/_workflow.py | 10 +- .../tests/workflow/test_function_executor.py | 9 +- .../workflow/test_functional_workflow.py | 1693 +++++++++++++++++ .../05_functional_workflow_with_agents.py | 49 + .../06_functional_workflow_basics.py | 57 + ...workflow.py => 07_first_graph_workflow.py} | 7 +- ...st_your_agent.py => 08_host_your_agent.py} | 0 python/samples/01-get-started/README.md | 6 +- python/samples/03-workflows/README.md | 14 + .../functional/agent_integration.py | 107 ++ .../03-workflows/functional/basic_pipeline.py | 58 + .../functional/basic_streaming_pipeline.py | 63 + .../03-workflows/functional/hitl_review.py | 84 + .../functional/naive_group_chat.py | 82 + .../functional/parallel_pipeline.py | 66 + .../functional/steps_and_checkpointing.py | 97 + python/samples/README.md | 6 +- 21 files changed, 3968 insertions(+), 15 deletions(-) create mode 100644 python/packages/core/agent_framework/_workflows/_functional.py create mode 100644 python/packages/core/tests/workflow/test_functional_workflow.py create mode 100644 python/samples/01-get-started/05_functional_workflow_with_agents.py create mode 100644 python/samples/01-get-started/06_functional_workflow_basics.py rename python/samples/01-get-started/{05_first_workflow.py => 07_first_graph_workflow.py} (87%) rename python/samples/01-get-started/{06_host_your_agent.py => 08_host_your_agent.py} (100%) create mode 100644 python/samples/03-workflows/functional/agent_integration.py create mode 100644 python/samples/03-workflows/functional/basic_pipeline.py create mode 100644 python/samples/03-workflows/functional/basic_streaming_pipeline.py create mode 100644 python/samples/03-workflows/functional/hitl_review.py create mode 100644 python/samples/03-workflows/functional/naive_group_chat.py create mode 100644 python/samples/03-workflows/functional/parallel_pipeline.py create mode 100644 python/samples/03-workflows/functional/steps_and_checkpointing.py diff --git a/python/packages/core/agent_framework/__init__.py b/python/packages/core/agent_framework/__init__.py index 05f65873bc..364f62eae1 100644 --- a/python/packages/core/agent_framework/__init__.py +++ b/python/packages/core/agent_framework/__init__.py @@ -213,6 +213,15 @@ from ._workflows._executor import ( handler, ) from ._workflows._function_executor import FunctionExecutor, executor +from ._workflows._functional import ( + FunctionalWorkflow, + FunctionalWorkflowAgent, + RunContext, + StepWrapper, + get_run_context, + step, + workflow, +) from ._workflows._request_info_mixin import response_handler from ._workflows._runner import Runner from ._workflows._runner_context import ( @@ -332,6 +341,8 @@ __all__ = [ "FunctionMiddleware", "FunctionMiddlewareTypes", "FunctionTool", + "FunctionalWorkflow", + "FunctionalWorkflowAgent", "GeneratedEmbeddings", "GraphConnectivityError", "HistoryProvider", @@ -354,6 +365,7 @@ __all__ = [ "ResponseStream", "Role", "RoleLiteral", + "RunContext", "Runner", "RunnerContext", "SecretString", @@ -366,6 +378,7 @@ __all__ = [ "SkillScriptRunner", "SkillsProvider", "SlidingWindowStrategy", + "StepWrapper", "SubWorkflowRequestMessage", "SubWorkflowResponseMessage", "SummarizationStrategy", @@ -424,6 +437,7 @@ __all__ = [ "evaluator", "executor", "function_middleware", + "get_run_context", "handler", "included_messages", "included_token_count", @@ -439,6 +453,7 @@ __all__ = [ "register_state_type", "resolve_agent_id", "response_handler", + "step", "tool", "tool_call_args_match", "tool_called_check", @@ -447,4 +462,5 @@ __all__ = [ "validate_tool_mode", "validate_tools", "validate_workflow_graph", + "workflow", ] diff --git a/python/packages/core/agent_framework/_feature_stage.py b/python/packages/core/agent_framework/_feature_stage.py index 761b7860a4..ef7dfd3687 100644 --- a/python/packages/core/agent_framework/_feature_stage.py +++ b/python/packages/core/agent_framework/_feature_stage.py @@ -48,6 +48,7 @@ class ExperimentalFeature(str, Enum): EVALS = "EVALS" FILE_HISTORY = "FILE_HISTORY" + FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS" SKILLS = "SKILLS" TOOLBOXES = "TOOLBOXES" diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index d26952d8e5..4b8238268c 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -120,6 +120,7 @@ WorkflowEventType = Literal[ "executor_invoked", # Executor handler was called (use .executor_id, .data) "executor_completed", # Executor handler completed (use .executor_id, .data) "executor_failed", # Executor handler raised error (use .executor_id, .details) + "executor_bypassed", # Executor skipped via cache hit during replay (use .executor_id, .data) # Orchestration event types (use .data for typed payload) "group_chat", # Group chat orchestrator events (use .data as GroupChatRequestSentEvent | GroupChatResponseReceivedEvent) # noqa: E501 "handoff_sent", # Handoff routing events (use .data as HandoffSentEvent) @@ -148,6 +149,7 @@ class WorkflowEvent(Generic[DataT]): - `WorkflowEvent.executor_invoked(executor_id)` - executor handler called - `WorkflowEvent.executor_completed(executor_id)` - executor handler completed - `WorkflowEvent.executor_failed(executor_id, details)` - executor handler failed + - `WorkflowEvent.executor_bypassed(executor_id)` - executor skipped via cache hit The generic parameter DataT represents the type of the event's data payload: - Lifecycle events: `WorkflowEvent[None]` (data is None) @@ -318,6 +320,11 @@ class WorkflowEvent(Generic[DataT]): """Create an 'executor_failed' event when an executor handler raises an error.""" return WorkflowEvent("executor_failed", executor_id=executor_id, data=details, details=details) + @classmethod + def executor_bypassed(cls, executor_id: str, data: DataT | None = None) -> WorkflowEvent[DataT]: + """Create an 'executor_bypassed' event when a step is skipped via cache hit during replay.""" + return cls("executor_bypassed", executor_id=executor_id, data=data) + # ========================================================================== # Property for type-safe access # ========================================================================== diff --git a/python/packages/core/agent_framework/_workflows/_functional.py b/python/packages/core/agent_framework/_workflows/_functional.py new file mode 100644 index 0000000000..159d75e137 --- /dev/null +++ b/python/packages/core/agent_framework/_workflows/_functional.py @@ -0,0 +1,1551 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Functional workflow API for writing workflows as plain async functions. + +.. warning:: Experimental + + This API is experimental and subject to change or removal + in future versions without notice. + +This module provides the ``@workflow`` and ``@step`` decorators that let users +define workflows using native Python control flow (if/else, loops, +``asyncio.gather``) instead of a graph-based topology. + +A ``@workflow``-decorated async function receives its input as the first +positional argument. If the function needs HITL (``request_info``), custom +events, or key/value state, add a :class:`RunContext` parameter — otherwise it +can be omitted. Inside the workflow, plain ``async`` calls run normally. +Optionally, ``@step``-decorated functions gain caching, per-step checkpointing, +and event emission. ``@step`` functions may also declare a ``RunContext`` +parameter to access HITL and state APIs directly. + +Key public symbols: + +* :func:`workflow` / :class:`FunctionalWorkflow` — decorator and runtime. +* :func:`step` / :class:`StepWrapper` — optional step decorator. +* :class:`RunContext` — execution context injected into workflow and step + functions. +* :func:`get_run_context` — retrieve the active ``RunContext`` from anywhere + inside a running workflow. +* :class:`FunctionalWorkflowAgent` — agent adapter returned by + :meth:`FunctionalWorkflow.as_agent`. +""" + +from __future__ import annotations + +# pyright: reportPrivateUsage=false +# Classes in this module (RunContext, StepWrapper, FunctionalWorkflow) form a +# cohesive unit and intentionally access each other's underscore-prefixed members. +import functools +import hashlib +import inspect +import logging +import typing +from collections.abc import AsyncIterable, Awaitable, Callable, Sequence +from contextvars import ContextVar +from copy import deepcopy +from typing import Any, Generic, Literal, TypeVar, overload + +from .._feature_stage import ExperimentalFeature, experimental +from .._types import AgentResponse, AgentResponseUpdate, ResponseStream +from ..observability import OtelAttr, capture_exception, create_workflow_span +from ._checkpoint import CheckpointStorage, WorkflowCheckpoint +from ._events import ( + WorkflowErrorDetails, + WorkflowEvent, + WorkflowRunState, + _framework_event_origin, # type: ignore[reportPrivateUsage] +) +from ._workflow import WorkflowRunResult + +logger = logging.getLogger(__name__) + +R = TypeVar("R") + +# ContextVar holding the active RunContext during workflow execution. +# ContextVar is per-asyncio-Task, so concurrent workflows each get their own context. +_active_run_ctx: ContextVar[RunContext | None] = ContextVar("_active_run_ctx", default=None) + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def get_run_context() -> RunContext | None: + """Return the active :class:`RunContext`, or ``None`` if not inside a ``@workflow``. + + This is useful inside ``@step`` functions (or any code called from a + workflow) that need access to HITL, state, or event APIs without + requiring a ``RunContext`` parameter. + """ + return _active_run_ctx.get() + + +# --------------------------------------------------------------------------- +# Internal exception for HITL interruption +# --------------------------------------------------------------------------- + + +class WorkflowInterrupted(BaseException): + """Internal: raised when request_info() is called during initial execution. + + Inherits from ``BaseException`` (not ``Exception``) so that user code + with ``except Exception:`` handlers inside a ``@workflow`` function does + not accidentally intercept the HITL interruption signal. + """ + + def __init__(self, request_id: str, request_data: Any, response_type: type) -> None: + self.request_id = request_id + self.request_data = request_data + self.response_type = response_type + super().__init__(f"Workflow interrupted by request_info (request_id={request_id})") + + +# --------------------------------------------------------------------------- +# RunContext +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class RunContext: + """Opt-in handle for workflow-only features inside a ``@workflow`` function. + + Use ``RunContext`` when a workflow function needs one of the following, + otherwise omit it entirely for a cleaner signature: + + * Human-in-the-loop: :meth:`request_info` pauses the workflow until a + response is supplied, then resumes with that value. + * Custom events: :meth:`add_event` emits events into the run stream + (useful for progress reporting or tracing). + * Workflow-scoped key/value state: :meth:`get_state` / :meth:`set_state` + persist values across a run and survive checkpoints. + + The context is injected automatically. Declare it either by parameter + name (``ctx``) or by type annotation (``: RunContext``); both work. + + Args: + workflow_name: Identifier for the enclosing workflow, used when + generating events and checkpoint metadata. + streaming: Whether the current run was started with ``stream=True``. + run_kwargs: Extra keyword arguments forwarded from + :meth:`FunctionalWorkflow.run`. + + Examples: + + .. code-block:: python + + # Simple workflow: no context parameter needed. + @workflow + async def my_pipeline(data: str) -> str: + return await some_step(data) + + + # HITL workflow: request a response from a human reviewer. + @workflow + async def hitl_pipeline(data: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": data}, response_type=str) + return feedback + + + # RunContext also works inside @step functions. + @step + async def review_step(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str) + return feedback + """ + + def __init__( + self, + workflow_name: str, + *, + streaming: bool = False, + run_kwargs: dict[str, Any] | None = None, + ) -> None: + self._workflow_name = workflow_name + self._streaming = streaming + self._run_kwargs = run_kwargs or {} + + # Event accumulator + self._events: list[WorkflowEvent[Any]] = [] + + # Step result cache: (step_name, call_index) -> result + self._step_cache: dict[tuple[str, int], Any] = {} + # Cached step metadata used to keep auto-generated request_info IDs in sync on bypass. + self._step_cache_auto_request_info_counts: dict[tuple[str, int], int] = {} + # Per-step call counters for deterministic cache keys + self._step_call_counters: dict[str, int] = {} + # Deterministic call counter for auto-generated request_info IDs + self._auto_request_info_index: int = 0 + + # HITL responses (set via _set_responses before replay) + self._responses: dict[str, Any] = {} + # Pending request_info events (for checkpointing) + self._pending_requests: dict[str, WorkflowEvent[Any]] = {} + + # User state (simple dict) + self._state: dict[str, Any] = {} + + # Callback invoked after each step completes (set by FunctionalWorkflow) + self._on_step_completed: Callable[[], Awaitable[None]] | None = None + + # ------------------------------------------------------------------ + # Public API (for @workflow functions) + # ------------------------------------------------------------------ + + async def request_info( + self, + request_data: Any, + response_type: type, + *, + request_id: str | None = None, + ) -> Any: + """Request external information (human-in-the-loop). + + On first execution this suspends the workflow by raising an internal + ``WorkflowInterrupted`` signal (caught by the framework, never exposed + to user code). The caller receives a ``WorkflowRunResult`` (or a + ``ResponseStream`` when ``stream=True``) whose + :meth:`~WorkflowRunResult.get_request_info_events` contains the pending + request. When the workflow is resumed with + ``run(responses={request_id: value})``, the same function re-executes + and ``request_info`` returns the provided *value* directly. + + Args: + request_data: Arbitrary payload describing what information is + needed (e.g. a Pydantic model, dict, or string prompt). + response_type: The expected Python type of the response value. + request_id: Optional stable identifier for this request. If + omitted, a deterministic identifier is derived from the call + order (``auto::``) so that resume works without the + caller needing to echo back an explicit ID. + + Returns: + The response value supplied during replay. ``None`` is allowed + but triggers a warning — prefer a sentinel value when the + absence of data is meaningful. + + Raises: + WorkflowInterrupted: Raised internally on initial execution + (not visible to workflow authors). + """ + if request_id is None: + # Deterministic id; same determinism contract as @step caching. + rid = f"auto::{self._auto_request_info_index}" + self._auto_request_info_index += 1 + else: + rid = request_id + + found, value = self._get_response(rid) + if found: + self._pending_requests.pop(rid, None) + return value + + # No response — emit event and interrupt + event = WorkflowEvent.request_info( + request_id=rid, + source_executor_id=self._workflow_name, + request_data=request_data, + response_type=response_type, + ) + await self.add_event(event) + self._pending_requests[rid] = event + raise WorkflowInterrupted(rid, request_data, response_type) + + async def add_event(self, event: WorkflowEvent[Any]) -> None: + """Add a custom event to the workflow event stream. + + Use this to inject application-specific events alongside the + framework-generated lifecycle events. + + Args: + event: The workflow event to append. + """ + self._events.append(event) + + def get_state(self, key: str, default: Any = None) -> Any: + """Retrieve a value from the workflow's key/value state. + + State values are persisted across HITL interruptions and are included + in checkpoints when checkpoint storage is configured. + + Args: + key: The state key to look up. + default: Value returned when *key* is absent. + + Returns: + The stored value, or *default* if the key does not exist. + """ + return self._state.get(key, default) + + def set_state(self, key: str, value: Any) -> None: + """Store a value in the workflow's key/value state. + + Args: + key: The state key. Must not start with ``_`` — framework + bookkeeping (e.g. ``_step_cache``, ``_original_message``) uses + the underscore prefix and user keys in that namespace are + silently clobbered by checkpoint save and dropped on + checkpoint restore. Use names without a leading underscore + for user state. + value: The value to store. Must be JSON-serializable if + checkpoint storage is used. + + Raises: + ValueError: If *key* begins with ``_`` (reserved for framework + bookkeeping). + """ + if key.startswith("_"): + raise ValueError( + f"State key {key!r} starts with '_', which is reserved for " + f"framework bookkeeping (e.g. '_step_cache', '_original_message') " + f"and would be silently dropped on checkpoint restore. Use a " + f"non-underscore-prefixed key for user state." + ) + self._state[key] = value + + def is_streaming(self) -> bool: + """Return whether the current run was started with ``stream=True``. + + Returns: + ``True`` if the workflow is running in streaming mode. + """ + return self._streaming + + # ------------------------------------------------------------------ + # Internal API (for StepWrapper and FunctionalWorkflow) + # ------------------------------------------------------------------ + + def _get_events(self) -> list[WorkflowEvent[Any]]: + return list(self._events) + + def _get_step_cache_key(self, step_name: str) -> tuple[str, int]: + idx = self._step_call_counters.get(step_name, 0) + self._step_call_counters[step_name] = idx + 1 + return (step_name, idx) + + def _get_cached_result(self, key: tuple[str, int]) -> tuple[bool, Any]: + if key in self._step_cache: + return True, self._step_cache[key] + return False, None + + def _set_cached_result(self, key: tuple[str, int], value: Any) -> None: + self._step_cache[key] = value + + def _set_cached_step_auto_request_info_count(self, key: tuple[str, int], count: int) -> None: + self._step_cache_auto_request_info_counts[key] = count + + def _advance_auto_request_info_index_for_cached_step(self, key: tuple[str, int]) -> None: + self._auto_request_info_index += self._step_cache_auto_request_info_counts.get(key, 0) + + def _set_responses(self, responses: dict[str, Any]) -> None: + for rid, value in responses.items(): + if value is None: + logger.warning( + "Response for request_id=%r is None. If this is intentional, " + "consider using a sentinel value instead.", + rid, + ) + self._responses = dict(responses) + # Remove resolved requests from the pending set so downstream + # checkpoints don't re-serialize them as still-pending. + for rid in responses: + self._pending_requests.pop(rid, None) + + def _get_response(self, request_id: str) -> tuple[bool, Any]: + """Look up a HITL response by *request_id*. + + Returns: + A ``(found, value)`` tuple. When *found* is ``True``, *value* is + the caller-supplied response (which **may be** ``None`` — a warning + is logged by :meth:`_set_responses` in that case). When *found* is + ``False``, *value* is always ``None`` and simply means no response + has been provided yet. + """ + if request_id in self._responses: + return True, self._responses[request_id] + return False, None + + def _export_step_cache(self) -> dict[str, Any]: + """Serialize the step cache for checkpointing. + + Converts tuple keys to strings for JSON compatibility. + """ + return {f"{name}::{idx}": val for (name, idx), val in self._step_cache.items()} + + def _export_step_cache_auto_request_info_counts(self) -> dict[str, int]: + """Serialize per-step auto request_info counts for checkpointing.""" + return {f"{name}::{idx}": count for (name, idx), count in self._step_cache_auto_request_info_counts.items()} + + def _import_step_cache(self, data: dict[str, Any]) -> None: + """Restore step cache from checkpoint data.""" + self._step_cache = {} + for k, v in data.items(): + try: + name, idx_str = k.rsplit("::", 1) + self._step_cache[name, int(idx_str)] = v + except (ValueError, TypeError) as exc: + raise ValueError( + f"Corrupted step cache entry in checkpoint: key={k!r}. " + f"The checkpoint may be from an incompatible version or corrupted. " + f"Original error: {exc}" + ) from exc + + def _import_step_cache_auto_request_info_counts(self, data: dict[str, Any]) -> None: + """Restore per-step auto request_info counts from checkpoint data.""" + self._step_cache_auto_request_info_counts = {} + for k, v in data.items(): + try: + name, idx_str = k.rsplit("::", 1) + self._step_cache_auto_request_info_counts[name, int(idx_str)] = int(v) + except (ValueError, TypeError) as exc: + raise ValueError( + f"Corrupted step cache request_info metadata in checkpoint: key={k!r}, value={v!r}. " + f"The checkpoint may be from an incompatible version or corrupted. " + f"Original error: {exc}" + ) from exc + + +# --------------------------------------------------------------------------- +# StepWrapper +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class StepWrapper(Generic[R]): + """Wrapper returned by the ``@step`` decorator. + + When called inside a running ``@workflow`` function, the wrapper + intercepts execution to provide: + + * **Caching** — results are cached by ``(step_name, call_index)`` so + that HITL replay and checkpoint restore skip already-completed work. + On cache hit a single ``executor_bypassed`` event is emitted instead + of the normal ``executor_invoked`` / ``executor_completed`` pair. + * **Event emission** — ``executor_invoked`` / ``executor_completed`` / + ``executor_failed`` events are emitted for observability. + * **RunContext injection** — if the step function declares a parameter + annotated as :class:`RunContext` (or named ``ctx``), the active + context is automatically injected, giving step functions access to + HITL, state, and event APIs. + * **Per-step checkpointing** — a checkpoint is saved after each live + execution when checkpoint storage is configured. + + Outside a workflow the wrapper is transparent: it delegates directly to + the original function, making decorated functions fully testable in + isolation. + + Args: + func: The async function to wrap. + name: Optional display name. Defaults to ``func.__name__``. + + Raises: + TypeError: If *func* is not an async (coroutine) function. + """ + + def __init__(self, func: Callable[..., Awaitable[R]], *, name: str | None = None) -> None: + if not inspect.iscoroutinefunction(func): + raise TypeError( + f"@step can only decorate async functions, but '{func.__name__}' is not a coroutine function." + ) + self._func = func + self.name: str = name or func.__name__ + self._signature = inspect.signature(func) + functools.update_wrapper(self, func) + + # Detect RunContext parameter for auto-injection inside workflows + self._ctx_param_name: str | None = None + try: + hints = typing.get_type_hints(func) + except Exception: + hints = {} + for param_name, param in self._signature.parameters.items(): + if param.kind not in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ): + continue + resolved = hints.get(param_name, param.annotation) + if resolved is RunContext or param_name == "ctx": + self._ctx_param_name = param_name + break + + def _build_call_args_with_ctx( + self, + ctx: RunContext, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Inject RunContext without consuming a user positional argument.""" + if self._ctx_param_name is None or self._ctx_param_name in kwargs: + return args, dict(kwargs) + + call_args: list[Any] = [] + call_kwargs = dict(kwargs) + arg_index = 0 + + for param in self._signature.parameters.values(): + if param.name == self._ctx_param_name: + if param.kind == inspect.Parameter.KEYWORD_ONLY: + call_kwargs[param.name] = ctx + else: + call_args.append(ctx) + continue + + if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD): + if arg_index < len(args): + call_args.append(args[arg_index]) + arg_index += 1 + elif param.kind == inspect.Parameter.VAR_POSITIONAL: + call_args.extend(args[arg_index:]) + arg_index = len(args) + + if arg_index < len(args): + call_args.extend(args[arg_index:]) + + return tuple(call_args), call_kwargs + + async def __call__(self, *args: Any, **kwargs: Any) -> R: + ctx = _active_run_ctx.get() + if ctx is None: + # Outside a workflow — pass through directly + return await self._func(*args, **kwargs) + + cache_key = ctx._get_step_cache_key(self.name) + found, cached = ctx._get_cached_result(cache_key) + if found: + ctx._advance_auto_request_info_index_for_cached_step(cache_key) + # Dedicated bypass event so consumers can tell cache-hit replays + # apart from fresh executions. + await ctx.add_event(WorkflowEvent.executor_bypassed(self.name, cached)) + return cached # type: ignore[return-value, no-any-return] + + # Inject RunContext if the step function declares it + call_args, call_kwargs = self._build_call_args_with_ctx(ctx, args, kwargs) + + # Defensive deepcopy for the event log only; fall back to the live + # reference so non-deepcopyable args (locks, sockets) don't fail. + if args or kwargs: + try: + invocation_data: Any = deepcopy({"args": args, "kwargs": kwargs}) + except Exception: + invocation_data = {"args": args, "kwargs": kwargs} + else: + invocation_data = None + await ctx.add_event(WorkflowEvent.executor_invoked(self.name, invocation_data)) + auto_request_info_index_before = ctx._auto_request_info_index + try: + result = await self._func(*call_args, **call_kwargs) + except Exception as exc: + # NOTE: WorkflowInterrupted (from request_info inside a step) inherits + # from BaseException, NOT Exception, so it propagates past this handler + # without emitting a spurious executor_failed event. This is intentional + # — request_info is fully supported inside @step functions. + await ctx.add_event(WorkflowEvent.executor_failed(self.name, WorkflowErrorDetails.from_exception(exc))) + raise + ctx._set_cached_step_auto_request_info_count( + cache_key, + ctx._auto_request_info_index - auto_request_info_index_before, + ) + ctx._set_cached_result(cache_key, result) + await ctx.add_event(WorkflowEvent.executor_completed(self.name, result)) + if ctx._on_step_completed is not None: + await ctx._on_step_completed() + return result + + +# --------------------------------------------------------------------------- +# @step decorator +# --------------------------------------------------------------------------- + + +@overload +def step(func: Callable[..., Awaitable[R]]) -> StepWrapper[R]: ... + + +@overload +def step(*, name: str | None = None) -> Callable[[Callable[..., Awaitable[R]]], StepWrapper[R]]: ... + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def step( + func: Callable[..., Awaitable[Any]] | None = None, + *, + name: str | None = None, +) -> StepWrapper[Any] | Callable[[Callable[..., Awaitable[Any]]], StepWrapper[Any]]: + """Decorator that marks an async function as a tracked workflow step. + + Supports both bare ``@step`` and parameterized ``@step(name="custom")`` + forms. Inside a running ``@workflow`` function, calls to a step are + intercepted for result caching, event emission, and per-step + checkpointing. If the step function declares a :class:`RunContext` + parameter (by type annotation or the name ``ctx``), the active context + is automatically injected, giving the step access to + :meth:`~RunContext.request_info`, state, and event APIs. Outside a + workflow the decorated function behaves identically to the original, + making it fully testable in isolation. + + The ``@step`` decorator is **optional**. Plain async functions work + inside ``@workflow`` without it; use ``@step`` only when you need + caching, checkpointing, or observability for a particular call. + + Args: + func: The async function to decorate (when using the bare + ``@step`` form). + name: Optional display name for the step. Defaults to the + function's ``__name__``. + + Returns: + A :class:`StepWrapper` (bare form) or a decorator that produces + one (parameterized form). + + Raises: + TypeError: If the decorated function is not async. + + Examples: + + .. code-block:: python + + @step + async def fetch_data(url: str) -> dict: + return await http_get(url) + + + @step(name="transform") + async def transform_data(raw: dict) -> str: + return json.dumps(raw) + + + # Step with HITL — RunContext is auto-injected inside a workflow: + @step + async def review(doc: str, ctx: RunContext) -> str: + return await ctx.request_info({"draft": doc}, response_type=str) + """ + if func is not None: + return StepWrapper(func, name=name) + + def _decorator(fn: Callable[..., Awaitable[Any]]) -> StepWrapper[Any]: + return StepWrapper(fn, name=name) + + return _decorator + + +# --------------------------------------------------------------------------- +# FunctionalWorkflow +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class FunctionalWorkflow: + """A workflow backed by a user-defined async function. + + Created by the :func:`workflow` decorator. Exposes the same ``run()`` + interface as graph-based :class:`Workflow` objects, returning a + :class:`WorkflowRunResult` (or a :class:`ResponseStream` in streaming + mode). + + The underlying function is executed directly — no graph compilation or + edge wiring is involved. Native Python control flow (``if``/``else``, + ``for``, ``asyncio.gather``) is used for branching and parallelism. + + Args: + func: The async function that implements the workflow logic. + name: Display name for the workflow. Defaults to ``func.__name__``. + description: Optional human-readable description. + checkpoint_storage: Default :class:`CheckpointStorage` used for + persisting step results and state between runs. Can be + overridden per-run via the *checkpoint_storage* parameter of + :meth:`run`. + + Examples: + + .. code-block:: python + + @workflow + async def my_pipeline(data: str) -> str: + return await to_upper(data) + + + result = await my_pipeline.run("hello") + print(result.get_outputs()) # ['HELLO'] + """ + + def __init__( + self, + func: Callable[..., Awaitable[Any]], + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + ) -> None: + self._func = func + self.name = name or func.__name__ + self.description = description + self._checkpoint_storage = checkpoint_storage + self._is_running = False + # Replay state: cleared on clean completion so later responses-only + # calls can't silently replay with stale data from a prior run. + self._last_message: Any = None + self._last_step_cache: dict[tuple[str, int], Any] = {} + self._last_step_cache_auto_request_info_counts: dict[tuple[str, int], int] = {} + self._last_pending_request_ids: set[str] = set() + + # Signature arity is validated once at decoration time. + self._non_ctx_param_names = self._classify_signature(func) + + # Discover step names referenced in the function for signature hash + self._step_names = self._discover_step_names(func) + + # Compute a stable signature hash + self.graph_signature_hash = self._compute_signature_hash() + + functools.update_wrapper(self, func) # type: ignore[arg-type] + + @staticmethod + def _classify_signature(func: Callable[..., Any]) -> list[str]: + """Return the names of non-ctx parameters, validating arity. + + A workflow function may declare at most one non-ctx parameter (which + receives the caller-supplied ``message``). Any extra non-ctx + parameters would be silently dropped by ``_execute``, so we reject + them at decoration time. + """ + try: + hints = typing.get_type_hints(func) + except Exception: + hints = {} + non_ctx: list[str] = [] + for param_name, param in inspect.signature(func).parameters.items(): + resolved = hints.get(param_name, param.annotation) + if resolved is RunContext or param_name == "ctx": + continue + if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + continue + non_ctx.append(param_name) + if len(non_ctx) > 1: + raise ValueError( + f"@workflow function '{func.__name__}' declares multiple non-RunContext " + f"parameters ({non_ctx}); at most one is supported (it receives the " + f"'message' argument passed to .run()). Combine the inputs into a " + f"single object or dict." + ) + return non_ctx + + # ------------------------------------------------------------------ + # run() — same overloaded interface as graph Workflow + # ------------------------------------------------------------------ + + @overload + def run( + self, + message: Any | None = None, + *, + stream: Literal[True], + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[WorkflowEvent[Any], WorkflowRunResult]: ... + + @overload + def run( + self, + message: Any | None = None, + *, + stream: Literal[False] = ..., + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + include_status_events: bool = False, + **kwargs: Any, + ) -> Awaitable[WorkflowRunResult]: ... + + def run( + self, + message: Any | None = None, + *, + stream: bool = False, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + include_status_events: bool = False, + **kwargs: Any, + ) -> ResponseStream[WorkflowEvent[Any], WorkflowRunResult] | Awaitable[WorkflowRunResult]: + """Run the functional workflow. + + At least one of *message*, *responses*, or *checkpoint_id* must be + provided. *message* starts a fresh run; *responses* resumes after a + HITL interruption; *checkpoint_id* restores from a previously saved + checkpoint. *responses* may be combined with *checkpoint_id* to + restore a checkpoint and inject HITL responses in a single call. + *message* is mutually exclusive with both *responses* and + *checkpoint_id*. + + Args: + message: Input data passed as the first positional argument to + the workflow function. + stream: If ``True``, return a :class:`ResponseStream` that + yields :class:`WorkflowEvent` instances as they are produced. + responses: HITL responses keyed by ``request_id``, used to + resume a workflow that was suspended by + :meth:`RunContext.request_info`. + checkpoint_id: Identifier of a checkpoint to restore from. + Requires *checkpoint_storage* to be set (here or on the + decorator). + checkpoint_storage: Override the default checkpoint storage + for this run. + include_status_events: When ``True`` (non-streaming only), + include status-change events in the result. + + Keyword Args: + **kwargs: Extra keyword arguments stored on + :attr:`RunContext._run_kwargs` and accessible to step + functions. + + Returns: + A :class:`WorkflowRunResult` (non-streaming) or a + :class:`ResponseStream` (streaming). + + Raises: + ValueError: If the combination of *message*, *responses*, and + *checkpoint_id* is invalid. + RuntimeError: If the workflow is already running (concurrent + execution is not allowed). + """ + self._validate_run_params(message, responses, checkpoint_id) + if responses and checkpoint_id is None: + # Require at least one response key to match a currently-pending + # request; prevents silent replay against stale state while still + # allowing callers to accumulate prior answers across multi-round + # HITL. + if not self._last_pending_request_ids: + raise ValueError( + f"responses={list(responses)!r} do not correspond to any pending request on " + f"workflow '{self.name}'. The workflow has no pending request_info events, " + f"so there is nothing to resume. Start a fresh run with 'message', or supply " + f"'checkpoint_id' to restore a specific checkpoint." + ) + if not (set(responses) & self._last_pending_request_ids): + raise ValueError( + f"responses={list(responses)!r} do not answer any of the currently-pending " + f"requests on workflow '{self.name}' ({sorted(self._last_pending_request_ids)!r}). " + f"Provide a response keyed by one of the pending request_ids." + ) + self._ensure_not_running() + + response_stream: ResponseStream[WorkflowEvent[Any], WorkflowRunResult] = ResponseStream( + self._run_core( + message=message, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + streaming=stream, + **kwargs, + ), + finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events), + cleanup_hooks=[self._run_cleanup], + ) + + if stream: + return response_stream + return response_stream.get_final_response() + + # ------------------------------------------------------------------ + # As agent + # ------------------------------------------------------------------ + + def as_agent( + self, + name: str | None = None, + *, + description: str | None = None, + context_providers: Sequence[Any] | None = None, + **kwargs: Any, + ) -> FunctionalWorkflowAgent: + """Wrap this workflow as an agent-compatible object. + + The returned :class:`FunctionalWorkflowAgent` exposes a ``run()`` + method that delegates to the workflow, surfaces ``request_info`` + events as function approval requests, and converts outputs into an + :class:`AgentResponse`. + + Signature mirrors graph :meth:`Workflow.as_agent` so polymorphic + code works over either flavor. + + Args: + name: Display name for the agent. Defaults to the workflow name. + description: Optional description override. Defaults to the + workflow's ``description``. + context_providers: Optional context providers to associate with + the agent. Stored for caller introspection. + **kwargs: Reserved for future parity with + :meth:`Workflow.as_agent`. + + Returns: + A :class:`FunctionalWorkflowAgent` wrapping this workflow. + """ + return FunctionalWorkflowAgent( + workflow=self, + name=name, + description=description, + context_providers=context_providers, + **kwargs, + ) + + # ------------------------------------------------------------------ + # Internal execution + # ------------------------------------------------------------------ + + async def _run_core( + self, + message: Any | None = None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + streaming: bool = False, + **kwargs: Any, + ) -> AsyncIterable[WorkflowEvent[Any]]: + storage = checkpoint_storage or self._checkpoint_storage + + # Build context + ctx = RunContext(self.name, streaming=streaming, run_kwargs=kwargs if kwargs else None) + + # Restore from checkpoint if requested + prev_checkpoint_id: str | None = None + if checkpoint_id is not None: + if storage is None: + raise ValueError( + "Cannot restore from checkpoint without checkpoint_storage. " + "Provide checkpoint_storage parameter or set it on the @workflow decorator." + ) + checkpoint = await storage.load(checkpoint_id) + if checkpoint.graph_signature_hash != self.graph_signature_hash: + raise ValueError( + f"Checkpoint '{checkpoint_id}' was created by a different version of workflow " + f"'{checkpoint.workflow_name}' and is not compatible with the current version. " + f"The workflow's step structure may have changed since this checkpoint was saved." + ) + prev_checkpoint_id = checkpoint_id + # Restore step cache + step_cache_data = checkpoint.state.get("_step_cache", {}) + ctx._import_step_cache(step_cache_data) + step_cache_auto_request_info_counts = checkpoint.state.get("_step_cache_auto_request_info_counts", {}) + ctx._import_step_cache_auto_request_info_counts(step_cache_auto_request_info_counts) + # Restore user state + ctx._state = {k: v for k, v in checkpoint.state.items() if not k.startswith("_")} + # Restore pending request info events + ctx._pending_requests = dict(checkpoint.pending_request_info_events) + # Restore original message for replay + if message is None: + message = checkpoint.state.get("_original_message") + + # For response-only replay (no checkpoint), restore cached state + if checkpoint_id is None and responses: + if message is None: + message = self._last_message + ctx._step_cache = dict(self._last_step_cache) + ctx._step_cache_auto_request_info_counts = dict(self._last_step_cache_auto_request_info_counts) + + # Store message for future replays + if message is not None: + self._last_message = message + + # Set responses for replay + if responses: + ctx._set_responses(responses) + + # Wire up per-step checkpointing + # Use a mutable list so the closure can update prev_checkpoint_id + ckpt_chain: list[str | None] = [prev_checkpoint_id] + if storage is not None: + + async def _on_step_completed() -> None: + ckpt_chain[0] = await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + ctx._on_step_completed = _on_step_completed + + # Tracing + attributes: dict[str, Any] = {OtelAttr.WORKFLOW_NAME: self.name} + if self.description: + attributes[OtelAttr.WORKFLOW_DESCRIPTION] = self.description + + with create_workflow_span(OtelAttr.WORKFLOW_RUN_SPAN, attributes) as span: + saw_request = False + try: + span.add_event(OtelAttr.WORKFLOW_STARTED) + + with _framework_event_origin(): + yield WorkflowEvent.started() + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS) + + # Execute the user function + return_value = await self._execute(ctx, message) + + # Emit the return value as the workflow output. + if return_value is not None: + await ctx.add_event(WorkflowEvent.output(self.name, return_value)) + + # Persist step cache for response-only replay + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + + # Yield collected events. + # NOTE: Events are buffered during _execute() and yielded after + # the user function completes. This is *not* true streaming — + # all events have already been produced by this point. True + # per-token streaming from inner agent calls is a future + # enhancement. + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + + # Save final checkpoint if storage is available + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + # Final status + if saw_request: + self._last_pending_request_ids = set(ctx._pending_requests) + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + else: + # Clean completion — drop cross-run replay state. + self._last_message = None + self._last_step_cache = {} + self._last_step_cache_auto_request_info_counts = {} + self._last_pending_request_ids = set() + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE) + + span.add_event(OtelAttr.WORKFLOW_COMPLETED) + + except WorkflowInterrupted: + # Persist step cache for response-only replay + self._last_step_cache = dict(ctx._step_cache) + self._last_step_cache_auto_request_info_counts = dict(ctx._step_cache_auto_request_info_counts) + self._last_pending_request_ids = set(ctx._pending_requests) + + # HITL interruption — yield events collected so far + for event in ctx._get_events(): + if event.type == "request_info": + saw_request = True + yield event + if event.type == "request_info": + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) + + # Save checkpoint + if storage is not None: + await self._save_checkpoint(ctx, storage, ckpt_chain[0]) + + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.IDLE_WITH_PENDING_REQUESTS) + + span.add_event(OtelAttr.WORKFLOW_COMPLETED) + + except Exception as exc: + # Yield any events collected before the failure + for event in ctx._get_events(): + yield event + + details = WorkflowErrorDetails.from_exception(exc) + with _framework_event_origin(): + yield WorkflowEvent.failed(details) + with _framework_event_origin(): + yield WorkflowEvent.status(WorkflowRunState.FAILED) + + span.add_event( + name=OtelAttr.WORKFLOW_ERROR, + attributes={ + "error.message": str(exc), + "error.type": type(exc).__name__, + }, + ) + capture_exception(span, exception=exc) + raise + + async def _execute(self, ctx: RunContext, message: Any) -> Any: + """Run the user's async function with the active context.""" + if message is not None and not self._non_ctx_param_names: + raise ValueError( + f"@workflow function '{self._func.__name__}' has no non-RunContext " + f"parameter to receive a message, but .run(message=...) was called " + f"with a non-None value. Either add a first parameter to the " + f"workflow function or omit 'message'." + ) + + token = _active_run_ctx.set(ctx) + try: + sig = inspect.signature(self._func) + params = list(sig.parameters.values()) + + # Resolve string annotations to actual types + try: + hints = typing.get_type_hints(self._func) + except Exception as exc: + logger.warning( + "Failed to resolve type hints for workflow function '%s': %s. " + "RunContext injection may not work if annotations are forward references.", + self._func.__name__, + exc, + ) + hints = {} + + # Build call arguments: inject RunContext and pass `message`. + # RunContext is detected by type annotation first, then by + # parameter name "ctx" — so both of these work: + # async def my_workflow(data: str, ctx: RunContext) -> str: + # async def my_workflow(data: str, ctx) -> str: + call_args: list[Any] = [] + message_injected = False + + for param in params: + resolved = hints.get(param.name, param.annotation) + if resolved is RunContext or param.name == "ctx": + call_args.append(ctx) + elif not message_injected: + # First non-ctx param gets the message + call_args.append(message) + message_injected = True + + return await self._func(*call_args) + finally: + _active_run_ctx.reset(token) + + # ------------------------------------------------------------------ + # Checkpoint helpers + # ------------------------------------------------------------------ + + async def _save_checkpoint( + self, + ctx: RunContext, + storage: CheckpointStorage, + previous_checkpoint_id: str | None = None, + ) -> str: + state = dict(ctx._state) + state["_step_cache"] = ctx._export_step_cache() + state["_step_cache_auto_request_info_counts"] = ctx._export_step_cache_auto_request_info_counts() + state["_original_message"] = self._last_message + + checkpoint = WorkflowCheckpoint( + workflow_name=self.name, + graph_signature_hash=self.graph_signature_hash, + previous_checkpoint_id=previous_checkpoint_id, + state=state, + pending_request_info_events=dict(ctx._pending_requests), + ) + return await storage.save(checkpoint) + + def _compute_signature_hash(self) -> str: + """Stable hash of the workflow's code shape. + + Mixes workflow name, statically-discovered step names, and a digest + of ``__code__.co_code`` + ``co_names``. The code digest catches + body changes that step-name discovery misses (e.g. attribute-access + step references). + """ + code = getattr(self._func, "__code__", None) + co_code_hex = hashlib.sha256(code.co_code).hexdigest() if code is not None else "" + co_names = tuple(sorted(code.co_names)) if code is not None else () + sig_data = { + "workflow": self.name, + "steps": sorted(self._step_names), + "co_code": co_code_hex, + "co_names": list(co_names), + } + import json + + canonical = json.dumps(sig_data, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + @staticmethod + def _discover_step_names(func: Callable[..., Any]) -> list[str]: + """Extract step names referenced by the workflow function. + + Inspects the function's ``__code__.co_names`` and global scope for + ``StepWrapper`` instances. Steps accessed via module or class + attributes (``my_steps.fetch``) are missed here, but + :meth:`_compute_signature_hash` still captures them through the + ``co_code`` digest. + """ + names: list[str] = [] + globs = getattr(func, "__globals__", {}) + code_names = getattr(getattr(func, "__code__", None), "co_names", ()) + for n in code_names: + obj = globs.get(n) + if isinstance(obj, StepWrapper): + names.append(obj.name) + return names + + # ------------------------------------------------------------------ + # Finalize / cleanup / validation (mirrors Workflow) + # ------------------------------------------------------------------ + + @staticmethod + def _finalize_events( + events: Sequence[WorkflowEvent[Any]], + *, + include_status_events: bool = False, + ) -> WorkflowRunResult: + filtered: list[WorkflowEvent[Any]] = [] + status_events: list[WorkflowEvent[Any]] = [] + + for ev in events: + if ev.type == "started": + continue + if ev.type == "status": + status_events.append(ev) + if include_status_events: + filtered.append(ev) + continue + filtered.append(ev) + + return WorkflowRunResult(filtered, status_events) + + @staticmethod + def _validate_run_params( + message: Any | None, + responses: dict[str, Any] | None, + checkpoint_id: str | None, + ) -> None: + if message is not None and responses is not None: + raise ValueError("Cannot provide both 'message' and 'responses'. Use one or the other.") + + if message is not None and checkpoint_id is not None: + raise ValueError("Cannot provide both 'message' and 'checkpoint_id'. Use one or the other.") + + if message is None and responses is None and checkpoint_id is None: + raise ValueError( + "Must provide at least one of: 'message' (new run), 'responses' (send responses), " + "or 'checkpoint_id' (resume from checkpoint)." + ) + + def _ensure_not_running(self) -> None: + if self._is_running: + raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.") + self._is_running = True + + async def _run_cleanup(self) -> None: + self._is_running = False + + +# --------------------------------------------------------------------------- +# @workflow decorator +# --------------------------------------------------------------------------- + + +@overload +def workflow(func: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: ... + + +@overload +def workflow( + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, +) -> Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: ... + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +def workflow( + func: Callable[..., Awaitable[Any]] | None = None, + *, + name: str | None = None, + description: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, +) -> FunctionalWorkflow | Callable[[Callable[..., Awaitable[Any]]], FunctionalWorkflow]: + """Decorator that converts an async function into a :class:`FunctionalWorkflow`. + + Supports both bare ``@workflow`` and parameterized + ``@workflow(name="my_wf")`` forms. + + The decorated function receives its input as the first positional argument + and a :class:`RunContext` instance wherever a parameter is annotated with + that type. The resulting :class:`FunctionalWorkflow` object exposes the + same ``run()`` interface as graph-based workflows. + + Args: + func: The async function to decorate (when using the bare + ``@workflow`` form). + name: Display name for the workflow. Defaults to ``func.__name__``. + description: Optional human-readable description. + checkpoint_storage: Default :class:`CheckpointStorage` for + persisting step results and workflow state. + + Returns: + A :class:`FunctionalWorkflow` (bare form) or a decorator that + produces one (parameterized form). + + Examples: + + .. code-block:: python + + # Bare form + @workflow + async def pipeline(data: str) -> str: + return await process(data) + + + # Parameterized form + @workflow(name="my_pipeline", checkpoint_storage=storage) + async def pipeline(data: str) -> str: ... + """ + if func is not None: + return FunctionalWorkflow(func, name=name, description=description, checkpoint_storage=checkpoint_storage) + + def _decorator(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflow: + return FunctionalWorkflow(fn, name=name, description=description, checkpoint_storage=checkpoint_storage) + + return _decorator + + +# --------------------------------------------------------------------------- +# FunctionalWorkflowAgent +# --------------------------------------------------------------------------- + + +@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS) +class FunctionalWorkflowAgent: + """Agent adapter for a :class:`FunctionalWorkflow`. + + Provides a ``run()`` method with the same overloaded signature as + :class:`BaseAgent` — returning an :class:`AgentResponse` (non-streaming) + or a :class:`ResponseStream[AgentResponseUpdate, AgentResponse]` + (streaming), making functional workflows usable anywhere an + agent-compatible object is expected. + + ``request_info`` events emitted by the underlying workflow are surfaced + as :class:`FunctionApprovalRequestContent` items (mirroring the graph + :class:`WorkflowAgent`), so HITL workflows are callable via this + adapter. Callers resume via ``responses=`` / ``checkpoint_id=``. + + Args: + workflow: The :class:`FunctionalWorkflow` to wrap. + name: Display name for the agent. Defaults to the workflow name. + description: Display description. Defaults to ``workflow.description``. + context_providers: Optional context providers stored for caller + introspection. + **kwargs: Reserved for future parity with :class:`WorkflowAgent`; + currently ignored. + """ + + REQUEST_INFO_FUNCTION_NAME: str = "request_info" + + def __init__( + self, + workflow: FunctionalWorkflow, + *, + name: str | None = None, + description: str | None = None, + context_providers: Sequence[Any] | None = None, + **kwargs: Any, + ) -> None: + # kwargs is accepted for signature parity with graph Workflow.as_agent + # but not otherwise consumed. + del kwargs + self._workflow = workflow + self.name = name or workflow.name + self.id = f"FunctionalWorkflowAgent_{self.name}" + self.description: str | None = description if description is not None else workflow.description + self.context_providers: Sequence[Any] | None = context_providers + self._pending_requests: dict[str, WorkflowEvent[Any]] = {} + + @property + def pending_requests(self) -> dict[str, WorkflowEvent[Any]]: + """Pending request_info events emitted during the last run.""" + return self._pending_requests + + @overload + def run( + self, + messages: Any | None = None, + *, + stream: Literal[True], + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: ... + + @overload + def run( + self, + messages: Any | None = None, + *, + stream: Literal[False] = ..., + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse]: ... + + def run( + self, + messages: Any | None = None, + *, + stream: bool = False, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse] | Awaitable[AgentResponse]: + """Run the underlying workflow and return the result as an agent response. + + Args: + messages: Input data forwarded to :meth:`FunctionalWorkflow.run`. + + Keyword Args: + stream: If ``True``, return a :class:`ResponseStream` of + :class:`AgentResponseUpdate` items. + responses: HITL responses keyed by ``request_id``, forwarded to + the underlying workflow so HITL resumes work via this agent. + checkpoint_id: Optional checkpoint to restore from. + checkpoint_storage: Override the workflow's default + :class:`CheckpointStorage` for this run. + **kwargs: Extra keyword arguments forwarded to the workflow run. + + Returns: + An :class:`AgentResponse` (non-streaming) or a + :class:`ResponseStream` (streaming). + """ + if stream: + return self._run_streaming( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + return self._run_non_streaming( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + + async def _run_non_streaming( + self, + messages: Any | None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> AgentResponse: + result = await self._workflow.run( + messages, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + return self._result_to_agent_response(result) + + def _run_streaming( + self, + messages: Any | None, + *, + responses: dict[str, Any] | None = None, + checkpoint_id: str | None = None, + checkpoint_storage: CheckpointStorage | None = None, + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse]: + from .._types import Content + + agent_name = self.name + # Clear per-run pending state up front + self._pending_requests = {} + workflow_stream = self._workflow.run( + messages, + stream=True, + responses=responses, + checkpoint_id=checkpoint_id, + checkpoint_storage=checkpoint_storage, + **kwargs, + ) + + async def _generate_updates() -> AsyncIterable[AgentResponseUpdate]: + async for event in workflow_stream: + if event.type == "output": + data = event.data + if isinstance(data, str): + contents: list[Content] = [Content.from_text(text=data)] + elif isinstance(data, Content): + contents = [data] + else: + contents = [Content.from_text(text=str(data))] + yield AgentResponseUpdate( + contents=contents, + role="assistant", + author_name=agent_name, + ) + elif event.type == "request_info": + approval = self._request_info_to_approval_request(event) + if approval is None: + continue + yield AgentResponseUpdate( + contents=[approval], + role="assistant", + author_name=agent_name, + ) + + return ResponseStream( + _generate_updates(), + finalizer=AgentResponse.from_updates, + ) + + def _request_info_to_approval_request(self, event: WorkflowEvent[Any]) -> Any: + """Convert a `request_info` event to `FunctionApprovalRequestContent`. + + Returns ``None`` if the event is missing a request_id (defensive; + `request_info` always sets one). + """ + from .._types import Content + + request_id = event.request_id + if not request_id: + return None + self._pending_requests[request_id] = event + function_call = Content.from_function_call( + call_id=request_id, + name=self.REQUEST_INFO_FUNCTION_NAME, + arguments={"request_id": request_id, "data": event.data}, + ) + return Content.from_function_approval_request( + id=request_id, + function_call=function_call, + additional_properties={"request_id": request_id}, + ) + + def _result_to_agent_response(self, result: WorkflowRunResult) -> AgentResponse: + from .._types import Content + from .._types import Message as Msg + + # Refresh pending_requests for this run. + self._pending_requests = {} + + messages: list[Msg] = [] + for output in result.get_outputs(): + if isinstance(output, str): + contents: list[Content] = [Content.from_text(text=output)] + elif isinstance(output, Content): + contents = [output] + else: + contents = [Content.from_text(text=str(output))] + messages.append(Msg("assistant", contents)) + + # Surface pending request_info events so HITL callers see them. + approval_contents: list[Content] = [] + for event in result.get_request_info_events(): + approval = self._request_info_to_approval_request(event) + if approval is not None: + approval_contents.append(approval) + if approval_contents: + messages.append(Msg("assistant", approval_contents)) + + return AgentResponse(messages=messages) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index fc26db8953..c452f62bc2 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -340,10 +340,10 @@ class Workflow(DictConvertible): # Emit explicit start/status events to the stream with _framework_event_origin(): started = WorkflowEvent.started() - yield started + yield started # noqa: RUF070 with _framework_event_origin(): in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS) - yield in_progress + yield in_progress # noqa: RUF070 # Reset context for a new run if supported if reset_context: @@ -388,7 +388,7 @@ class Workflow(DictConvertible): emitted_in_progress_pending = True with _framework_event_origin(): pending_status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS) - yield pending_status + yield pending_status # noqa: RUF070 # Workflow runs until idle - emit final status based on whether requests are pending if saw_request: with _framework_event_origin(): @@ -409,10 +409,10 @@ class Workflow(DictConvertible): details = WorkflowErrorDetails.from_exception(exc) with _framework_event_origin(): failed_event = WorkflowEvent.failed(details) - yield failed_event + yield failed_event # noqa: RUF070 with _framework_event_origin(): failed_status = WorkflowEvent.status(WorkflowRunState.FAILED) - yield failed_status + yield failed_status # noqa: RUF070 span.add_event( name=OtelAttr.WORKFLOW_ERROR, attributes={ diff --git a/python/packages/core/tests/workflow/test_function_executor.py b/python/packages/core/tests/workflow/test_function_executor.py index 6d292cb6eb..b45a667722 100644 --- a/python/packages/core/tests/workflow/test_function_executor.py +++ b/python/packages/core/tests/workflow/test_function_executor.py @@ -529,11 +529,12 @@ class TestFunctionExecutor: assert "@handler on instance methods" in str(exc_info.value) async def test_async_staticmethod_detection_behavior(self): - """Document the behavior of asyncio.iscoroutinefunction with staticmethod descriptors. + """Document the behavior of inspect.iscoroutinefunction with staticmethod descriptors. This test explains why the unwrapping is necessary when decorators are stacked. """ import asyncio + import inspect # When @staticmethod is applied, it creates a descriptor async def my_async_func(): @@ -544,19 +545,19 @@ class TestFunctionExecutor: static_wrapped = staticmethod(my_async_func) # Direct check on descriptor object fails (this is the bug) - assert not asyncio.iscoroutinefunction(static_wrapped) # type: ignore[reportDeprecated] + assert not inspect.iscoroutinefunction(static_wrapped) assert isinstance(static_wrapped, staticmethod) # But unwrapping __func__ reveals the async function unwrapped = static_wrapped.__func__ - assert asyncio.iscoroutinefunction(unwrapped) # type: ignore[reportDeprecated] + assert inspect.iscoroutinefunction(unwrapped) # When accessed via class attribute, Python's descriptor protocol # automatically unwraps it, so it works: class C: async_static = static_wrapped - assert asyncio.iscoroutinefunction(C.async_static) # type: ignore[reportDeprecated] # Works via descriptor protocol + assert inspect.iscoroutinefunction(C.async_static) # Works via descriptor protocol class TestExecutorExplicitTypes: diff --git a/python/packages/core/tests/workflow/test_functional_workflow.py b/python/packages/core/tests/workflow/test_functional_workflow.py new file mode 100644 index 0000000000..ba465ffe0b --- /dev/null +++ b/python/packages/core/tests/workflow/test_functional_workflow.py @@ -0,0 +1,1693 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for the functional workflow API (@workflow, @step, RunContext).""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass + +import pytest + +from agent_framework import ( + AgentResponseUpdate, + ExperimentalFeature, + FunctionalWorkflow, + FunctionalWorkflowAgent, + InMemoryCheckpointStorage, + RunContext, + StepWrapper, + WorkflowEvent, + WorkflowRunResult, + WorkflowRunState, + get_run_context, + step, + workflow, +) +from agent_framework._workflows._functional import ( + RunContext as _RunContext, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@step +async def add_one(x: int) -> int: + return x + 1 + + +@step +async def double(x: int) -> int: + return x * 2 + + +@step +async def to_upper(s: str) -> str: + return s.upper() + + +@step(name="custom_name") +async def named_step(x: int) -> int: + return x + 10 + + +@step +async def failing_step(x: int) -> int: + raise ValueError(f"step failed with {x}") + + +# --------------------------------------------------------------------------- +# Basic execution +# --------------------------------------------------------------------------- + + +class TestBasicExecution: + async def test_simple_sequential_pipeline(self): + @workflow + async def pipeline(x: int) -> int: + a = await add_one(x) + return await double(a) + + result = await pipeline.run(5) + assert isinstance(result, WorkflowRunResult) + outputs = result.get_outputs() + assert outputs == [12] # (5+1)*2 + + async def test_workflow_with_string_data(self): + @workflow + async def upper_pipeline(text: str) -> str: + return await to_upper(text) + + result = await upper_pipeline.run("hello") + assert result.get_outputs() == ["HELLO"] + + async def test_workflow_returns_result(self): + @workflow + async def simple(x: int) -> int: + return await add_one(x) + + result = await simple.run(10) + assert result.get_outputs() == [11] + + async def test_workflow_name_defaults_to_function_name(self): + @workflow + async def my_pipeline(x: int) -> int: + return x + + assert my_pipeline.name == "my_pipeline" + + async def test_workflow_custom_name(self): + @workflow(name="custom_wf", description="A test workflow") + async def wf(x: int) -> int: + return x + + assert wf.name == "custom_wf" + assert wf.description == "A test workflow" + + +# --------------------------------------------------------------------------- +# Event emission +# --------------------------------------------------------------------------- + + +class TestEventEmission: + async def test_step_events_emitted(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + result = await pipeline.run(5) + event_types = [e.type for e in result] + assert "executor_invoked" in event_types + assert "executor_completed" in event_types + assert "output" in event_types + + async def test_step_events_carry_executor_id(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + result = await pipeline.run(5) + invoked_events = [e for e in result if e.type == "executor_invoked"] + assert len(invoked_events) == 1 + assert invoked_events[0].executor_id == "add_one" + + completed_events = [e for e in result if e.type == "executor_completed"] + assert len(completed_events) == 1 + assert completed_events[0].executor_id == "add_one" + assert completed_events[0].data == 6 + + async def test_status_events_in_timeline(self): + @workflow + async def pipeline(x: int) -> int: + return x + + result = await pipeline.run(1) + states = [e.state for e in result.status_timeline()] + assert WorkflowRunState.IN_PROGRESS in states + assert WorkflowRunState.IDLE in states + + async def test_final_state_is_idle(self): + @workflow + async def pipeline(x: int) -> int: + return x + + result = await pipeline.run(1) + assert result.get_final_state() == WorkflowRunState.IDLE + + async def test_custom_event(self): + from agent_framework import WorkflowEvent + + @workflow + async def pipeline(x: int, ctx: RunContext) -> int: + await ctx.add_event(WorkflowEvent.emit("pipeline", "custom_data")) + return x + + result = await pipeline.run(1) + data_events = [e for e in result if e.type == "data"] + assert len(data_events) == 1 + assert data_events[0].data == "custom_data" + + +# --------------------------------------------------------------------------- +# Parallel execution +# --------------------------------------------------------------------------- + + +class TestParallelExecution: + async def test_parallel_tasks_with_gather(self): + @step + async def slow_add(x: int) -> int: + await asyncio.sleep(0.01) + return x + 1 + + @step + async def slow_double(x: int) -> int: + await asyncio.sleep(0.01) + return x * 2 + + @workflow + async def parallel_wf(x: int) -> list[int]: + a, b = await asyncio.gather(slow_add(x), slow_double(x)) + return [a, b] + + result = await parallel_wf.run(5) + outputs = result.get_outputs() + assert outputs == [[6, 10]] + + async def test_parallel_events_all_emitted(self): + @step + async def task_a(x: int) -> int: + return x + 1 + + @step + async def task_b(x: int) -> int: + return x * 2 + + @workflow + async def par_wf(x: int) -> tuple[int, int]: + a, b = await asyncio.gather(task_a(x), task_b(x)) + return (a, b) + + result = await par_wf.run(3) + invoked = [e for e in result if e.type == "executor_invoked"] + completed = [e for e in result if e.type == "executor_completed"] + assert len(invoked) == 2 + assert len(completed) == 2 + + +# --------------------------------------------------------------------------- +# HITL (request_info / resume) +# --------------------------------------------------------------------------- + + +class TestHITL: + async def test_request_info_interrupts(self): + @workflow + async def review_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Final: {feedback}" + + # Phase 1: should interrupt with pending request + result = await review_wf.run("my doc") + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + request_events = result.get_request_info_events() + assert len(request_events) == 1 + assert request_events[0].request_id == "req1" + + async def test_request_info_resume(self): + @workflow + async def review_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Final: {feedback}" + + # Phase 1 + result1 = await review_wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume with response + result2 = await review_wf.run(responses={"req1": "Looks great!"}) + outputs = result2.get_outputs() + assert outputs == ["Final: Looks great!"] + assert result2.get_final_state() == WorkflowRunState.IDLE + + async def test_untyped_ctx_parameter(self): + """ctx is injected by parameter name even without a RunContext annotation.""" + + @workflow # pyright: ignore[reportUnknownArgumentType] + async def review_wf(doc: str, ctx) -> str: # pyright: ignore[reportUnknownParameterType,reportMissingParameterType] + feedback: str = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] + return f"Final: {feedback}" + + result1 = await review_wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + result2 = await review_wf.run(responses={"req1": "LGTM"}) + assert result2.get_outputs() == ["Final: LGTM"] + + async def test_multiple_sequential_interrupts(self): + @workflow + async def multi_hitl(data: str, ctx: RunContext) -> str: + r1 = await ctx.request_info("step1", response_type=str, request_id="r1") + r2 = await ctx.request_info("step2", response_type=str, request_id="r2") + return f"{r1}+{r2}" + + # Phase 1: first interrupt + result1 = await multi_hitl.run("start") + assert len(result1.get_request_info_events()) == 1 + assert result1.get_request_info_events()[0].request_id == "r1" + + # Phase 2: respond to first, hits second + result2 = await multi_hitl.run(responses={"r1": "A"}) + assert len(result2.get_request_info_events()) == 1 + assert result2.get_request_info_events()[0].request_id == "r2" + + # Phase 3: respond to second + result3 = await multi_hitl.run(responses={"r1": "A", "r2": "B"}) + assert result3.get_outputs() == ["A+B"] + + async def test_request_info_auto_generates_id(self): + @workflow + async def auto_id_wf(x: int, ctx: RunContext) -> None: + await ctx.request_info("need data", response_type=str) + + result = await auto_id_wf.run(1) + events = result.get_request_info_events() + assert len(events) == 1 + assert events[0].request_id # should be a non-empty uuid string + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + + +class TestErrorHandling: + async def test_step_failure_propagates(self): + @workflow + async def failing_wf(x: int) -> None: + await failing_step(x) + + with pytest.raises(ValueError, match="step failed with 42"): + await failing_wf.run(42) + + async def test_step_failure_emits_executor_failed(self): + @workflow + async def failing_wf(x: int) -> None: + await failing_step(x) + + # Use stream to collect events before the raise + stream = failing_wf.run(42, stream=True) + events: list[WorkflowEvent[object]] = [] + with pytest.raises(ValueError): + async for event in stream: + events.append(event) + + failed_events = [e for e in events if e.type == "executor_failed"] + assert len(failed_events) == 1 + assert failed_events[0].executor_id == "failing_step" + + async def test_workflow_failure_emits_failed_status(self): + @workflow + async def bad_wf(x: int) -> None: + raise RuntimeError("workflow broke") + + stream = bad_wf.run(42, stream=True) + events: list[WorkflowEvent[object]] = [] + with pytest.raises(RuntimeError, match="workflow broke"): + async for event in stream: + events.append(event) + + failed_events = [e for e in events if e.type == "failed"] + assert len(failed_events) == 1 + status_events = [e for e in events if e.type == "status"] + assert any(e.state == WorkflowRunState.FAILED for e in status_events) + + async def test_invalid_params_message_and_responses(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Cannot provide both"): + await wf.run("hello", responses={"r1": "val"}) + + async def test_invalid_params_message_and_checkpoint(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Cannot provide both"): + await wf.run("hello", checkpoint_id="abc") + + async def test_invalid_params_nothing(self): + @workflow + async def wf(x: int) -> None: + pass + + with pytest.raises(ValueError, match="Must provide at least one"): + await wf.run() + + +# --------------------------------------------------------------------------- +# Streaming +# --------------------------------------------------------------------------- + + +class TestStreaming: + async def test_streaming_yields_events(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + stream = pipeline.run(5, stream=True) + events: list[WorkflowEvent[object]] = [] + async for event in stream: + events.append(event) + + event_types = [e.type for e in events] + assert "started" in event_types + assert "executor_invoked" in event_types + assert "executor_completed" in event_types + assert "output" in event_types + + async def test_streaming_final_response(self): + @workflow + async def pipeline(x: int) -> int: + return await add_one(x) + + stream = pipeline.run(5, stream=True) + final = await stream.get_final_response() + assert isinstance(final, WorkflowRunResult) + assert final.get_outputs() == [6] + + async def test_streaming_context_reports_streaming(self): + streaming_flag = None + + @workflow + async def wf(x: int, ctx: RunContext) -> int: + nonlocal streaming_flag + streaming_flag = ctx.is_streaming() + return x + + stream = wf.run(1, stream=True) + await stream.get_final_response() + assert streaming_flag is True + + streaming_flag = None + await wf.run(1) + assert streaming_flag is False + + +# --------------------------------------------------------------------------- +# Step passthrough outside workflow +# --------------------------------------------------------------------------- + + +class TestStepPassthrough: + async def test_step_works_outside_workflow(self): + result = await add_one(10) + assert result == 11 + + async def test_named_step_outside_workflow(self): + result = await named_step(5) + assert result == 15 + + def test_step_wrapper_name(self): + assert add_one.name == "add_one" + assert named_step.name == "custom_name" + + def test_step_wrapper_is_step_wrapper(self): + assert isinstance(add_one, StepWrapper) + assert isinstance(named_step, StepWrapper) + + +# --------------------------------------------------------------------------- +# State management +# --------------------------------------------------------------------------- + + +class TestStateManagement: + async def test_get_set_state(self): + @workflow + async def stateful_wf(x: int, ctx: RunContext) -> int: + ctx.set_state("counter", x) + return ctx.get_state("counter") + + result = await stateful_wf.run(42) + assert result.get_outputs() == [42] + + async def test_get_state_default(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + return ctx.get_state("missing", "default_val") + + result = await wf.run(1) + assert result.get_outputs() == ["default_val"] + + +# --------------------------------------------------------------------------- +# Checkpointing +# --------------------------------------------------------------------------- + + +class TestCheckpointing: + async def test_checkpoint_save_and_restore(self): + storage = InMemoryCheckpointStorage() + + @step + async def expensive(x: int) -> int: + return x * 100 + + @workflow(checkpoint_storage=storage) + async def ckpt_wf(x: int) -> int: + return await expensive(x) + + result = await ckpt_wf.run(5) + assert result.get_outputs() == [500] + + # Verify checkpoints were saved: 1 per-step + 1 final + checkpoints = await storage.list_checkpoints(workflow_name="ckpt_wf") + assert len(checkpoints) == 2 + + async def test_checkpoint_runtime_storage_override(self): + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x + 1 + + @workflow + async def wf(x: int) -> int: + return await compute(x) + + result = await wf.run(10, checkpoint_storage=storage) + assert result.get_outputs() == [11] + # 1 per-step checkpoint + 1 final checkpoint + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 2 + + async def test_checkpoint_restore_replays_cached_tasks(self): + storage = InMemoryCheckpointStorage() + call_count = 0 + + @step + async def counting_task(x: int) -> int: + nonlocal call_count + call_count += 1 + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await counting_task(x) + + # First run + result1 = await wf.run(5) + assert result1.get_outputs() == [6] + assert call_count == 1 + + # Get checkpoint ID + checkpoints = await storage.list_checkpoints(workflow_name="wf") + ckpt_id = checkpoints[0].checkpoint_id + + # Restore — step should replay from cache + result2 = await wf.run(checkpoint_id=ckpt_id) + assert result2.get_outputs() == [6] + assert call_count == 1 # not called again + + async def test_checkpoint_hitl_resume(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def hitl_wf(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1") + return f"Done: {feedback}" + + # Phase 1: interrupt + result1 = await hitl_wf.run("draft text") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Get checkpoint + checkpoints = await storage.list_checkpoints(workflow_name="hitl_wf") + ckpt_id = checkpoints[0].checkpoint_id + + # Phase 2: restore and respond + result2 = await hitl_wf.run(checkpoint_id=ckpt_id, responses={"req1": "Approved!"}) + assert result2.get_outputs() == ["Done: Approved!"] + + async def test_checkpoint_without_storage_raises(self): + @workflow + async def wf(x: int) -> int: + return x + + with pytest.raises(ValueError, match="checkpoint_storage"): + await wf.run(checkpoint_id="nonexistent") + + async def test_checkpoint_preserves_state(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def stateful_wf(x: int, ctx: RunContext) -> str: + ctx.set_state("key", "value") + feedback = await ctx.request_info("need info", response_type=str, request_id="r1") + val = ctx.get_state("key") + return f"{val}:{feedback}" + + # Phase 1 + result1 = await stateful_wf.run(1) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: restore and respond + checkpoints = await storage.list_checkpoints(workflow_name="stateful_wf") + ckpt_id = checkpoints[0].checkpoint_id + + result2 = await stateful_wf.run(checkpoint_id=ckpt_id, responses={"r1": "hello"}) + assert result2.get_outputs() == ["value:hello"] + + async def test_per_step_checkpoint_enables_crash_recovery(self): + """Simulates crash recovery: step 1 completes and is checkpointed, + then the workflow crashes in step 2. Restoring from the per-step + checkpoint should replay step 1 from cache without re-executing it.""" + storage = InMemoryCheckpointStorage() + step1_calls = 0 + step2_calls = 0 + + @step + async def slow_step1(x: int) -> int: + nonlocal step1_calls + step1_calls += 1 + return x + 10 + + @step + async def crashing_step2(x: int) -> int: + nonlocal step2_calls + step2_calls += 1 + if step2_calls == 1: + raise RuntimeError("simulated crash") + return x * 2 + + @workflow(checkpoint_storage=storage) + async def crash_wf(x: int) -> int: + a = await slow_step1(x) + return await crashing_step2(a) + + # First run: step1 succeeds and checkpoints, step2 crashes + with pytest.raises(RuntimeError, match="simulated crash"): + await crash_wf.run(5) + + assert step1_calls == 1 + assert step2_calls == 1 + + # A per-step checkpoint was saved after step1 completed + checkpoints = await storage.list_checkpoints(workflow_name="crash_wf") + assert len(checkpoints) >= 1 + ckpt_id = checkpoints[0].checkpoint_id + + # Restore from checkpoint: step1 replays from cache, step2 runs fresh + result = await crash_wf.run(checkpoint_id=ckpt_id) + assert result.get_outputs() == [30] # (5+10)*2 + assert step1_calls == 1 # NOT called again — replayed from cache + assert step2_calls == 2 # called again, succeeds this time + + async def test_per_step_checkpoint_chain(self): + """Each step creates a new checkpoint chained to the previous one.""" + storage = InMemoryCheckpointStorage() + + @step + async def s1(x: int) -> int: + return x + 1 + + @step + async def s2(x: int) -> int: + return x + 2 + + @step + async def s3(x: int) -> int: + return x + 3 + + @workflow(checkpoint_storage=storage) + async def multi_step_wf(x: int) -> int: + a = await s1(x) + b = await s2(a) + return await s3(b) + + result = await multi_step_wf.run(0) + assert result.get_outputs() == [6] # 0+1+2+3 + + # 3 per-step checkpoints + 1 final = 4 + checkpoints = await storage.list_checkpoints(workflow_name="multi_step_wf") + assert len(checkpoints) == 4 + + async def test_no_checkpoint_on_cache_hit(self): + """During replay, cached steps should NOT create additional checkpoints.""" + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await compute(x) + + # First run: 1 per-step + 1 final = 2 checkpoints + await wf.run(5) + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 2 + ckpt_id = checkpoints[0].checkpoint_id + + # Restore: step replays from cache (no new per-step checkpoint), + # but final checkpoint still saved = 1 new checkpoint + await wf.run(checkpoint_id=ckpt_id) + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert len(checkpoints) == 3 # 2 from first run + 1 final from restore + + +# --------------------------------------------------------------------------- +# Branching / control flow +# --------------------------------------------------------------------------- + + +class TestControlFlow: + async def test_if_else_branching(self): + @dataclass + class Classification: + is_spam: bool + + @step + async def classify(text: str) -> Classification: + return Classification(is_spam="spam" in text.lower()) + + @step + async def process_normal(text: str) -> str: + return f"processed: {text}" + + @step + async def quarantine(text: str) -> str: + return f"quarantined: {text}" + + @workflow + async def email_pipeline(email: str) -> str: + cl = await classify(email) + if cl.is_spam: + result = await quarantine(email) + else: + result = await process_normal(email) + return result + + result_spam = await email_pipeline.run("Buy spam now!") + assert result_spam.get_outputs() == ["quarantined: Buy spam now!"] + + result_normal = await email_pipeline.run("Hello friend") + assert result_normal.get_outputs() == ["processed: Hello friend"] + + +# --------------------------------------------------------------------------- +# Nested workflow calls +# --------------------------------------------------------------------------- + + +class TestNestedWorkflows: + async def test_nested_workflow_as_task(self): + @step + async def step_a(x: int) -> int: + return x + 1 + + @workflow + async def inner_wf(x: int) -> int: + return await step_a(x) + + @step + async def call_inner(x: int) -> int: + result = await inner_wf.run(x) + return result.get_outputs()[0] + + @workflow + async def outer_wf(x: int) -> int: + return await call_inner(x) + + result = await outer_wf.run(5) + assert result.get_outputs() == [6] + + +# --------------------------------------------------------------------------- +# as_agent() +# --------------------------------------------------------------------------- + + +class TestAsAgent: + async def test_as_agent_returns_agent(self): + @workflow + async def wf(x: int) -> str: + return f"result: {x}" + + agent = wf.as_agent() + assert agent.name == "wf" + + async def test_as_agent_custom_name(self): + @workflow + async def wf(x: int) -> int: + return x + + agent = wf.as_agent(name="my_agent") + assert agent.name == "my_agent" + + async def test_as_agent_run(self): + @workflow + async def wf(x: int) -> int: + return await add_one(x) + + agent = wf.as_agent() + response = await agent.run(10) + assert response.text == "11" + + async def test_as_agent_run_streaming(self): + @workflow + async def wf(x: int) -> str: + return f"result: {x}" + + agent = wf.as_agent() + stream = agent.run(10, stream=True) + updates: list[AgentResponseUpdate] = [] + async for update in stream: + updates.append(update) + assert len(updates) == 1 + assert updates[0].text == "result: 10" + + response = await stream.get_final_response() + assert len(response.messages) >= 1 + + async def test_as_agent_has_id_and_description(self): + @workflow(description="A test workflow") + async def wf(x: int) -> int: + return x + + agent = wf.as_agent(name="my_agent") + assert agent.id == "FunctionalWorkflowAgent_my_agent" + assert agent.description == "A test workflow" + + +# --------------------------------------------------------------------------- +# Concurrent execution guard +# --------------------------------------------------------------------------- + + +class TestConcurrencyGuard: + async def test_concurrent_run_raises(self): + @workflow + async def slow_wf(x: int) -> int: + await asyncio.sleep(0.1) + return x + + # Start first run + stream = slow_wf.run(1, stream=True) + + # Try to start second run while first is active + with pytest.raises(RuntimeError, match="already running"): + slow_wf.run(2, stream=True) + + # Consume the stream to clean up + await stream.get_final_response() + + async def test_run_after_completion(self): + @workflow + async def wf(x: int) -> int: + return x + + result1 = await wf.run(1) + assert result1.get_outputs() == [1] + + # Should be able to run again after first completes + result2 = await wf.run(2) + assert result2.get_outputs() == [2] + + +# --------------------------------------------------------------------------- +# Decorator forms +# --------------------------------------------------------------------------- + + +class TestDecoratorForms: + def test_step_bare_decorator(self): + @step + async def my_step(x: int) -> int: + return x + + assert isinstance(my_step, StepWrapper) + assert my_step.name == "my_step" + + def test_step_with_name(self): + @step(name="renamed") + async def my_step(x: int) -> int: + return x + + assert isinstance(my_step, StepWrapper) + assert my_step.name == "renamed" + + def test_workflow_bare_decorator(self): + @workflow + async def my_wf(x: int) -> None: + pass + + assert isinstance(my_wf, FunctionalWorkflow) + assert my_wf.name == "my_wf" + + def test_workflow_with_params(self): + @workflow(name="custom", description="desc") + async def my_wf(x: int) -> None: + pass + + assert isinstance(my_wf, FunctionalWorkflow) + assert my_wf.name == "custom" + assert my_wf.description == "desc" + + +# --------------------------------------------------------------------------- +# include_status_events +# --------------------------------------------------------------------------- + + +class TestIncludeStatusEvents: + async def test_status_events_excluded_by_default(self): + @workflow + async def wf(x: int) -> int: + return x + + result = await wf.run(1) + status_in_list = [e for e in result if e.type == "status"] + assert len(status_in_list) == 0 + + async def test_status_events_included_when_requested(self): + @workflow + async def wf(x: int) -> int: + return x + + result = await wf.run(1, include_status_events=True) + status_in_list = [e for e in result if e.type == "status"] + assert len(status_in_list) > 0 + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + + +class TestEdgeCases: + async def test_workflow_with_no_tasks(self): + @workflow + async def no_tasks(x: int) -> int: + return x * 2 + + result = await no_tasks.run(5) + assert result.get_outputs() == [10] + + async def test_workflow_with_no_output(self): + @workflow + async def silent_wf(x: int) -> None: + pass # returns None — no output emitted + + result = await silent_wf.run(5) + assert result.get_outputs() == [] + + async def test_return_value_auto_yields_output(self): + """Returning a non-None value automatically emits it as an output.""" + + @workflow + async def wf(x: int) -> int: + return x * 3 + + result = await wf.run(5) + assert result.get_outputs() == [15] + + async def test_step_called_multiple_times(self): + @workflow + async def wf(x: int) -> int: + a = await add_one(x) + b = await add_one(a) + return await add_one(b) + + result = await wf.run(0) + assert result.get_outputs() == [3] # 0+1+1+1 + + # Should have 3 invoked and 3 completed events for add_one + invoked = [e for e in result if e.type == "executor_invoked"] + completed = [e for e in result if e.type == "executor_completed"] + assert len(invoked) == 3 + assert len(completed) == 3 + + +# --------------------------------------------------------------------------- +# Recovery after errors +# --------------------------------------------------------------------------- + + +class TestRecoveryAfterErrors: + async def test_run_after_failure_is_allowed(self): + @workflow + async def wf(x: int) -> int: + if x == 1: + raise RuntimeError("boom") + return x + + with pytest.raises(RuntimeError, match="boom"): + await wf.run(1) + + # Must be able to run again after the failure + result = await wf.run(2) + assert result.get_outputs() == [2] + + async def test_step_sync_function_raises(self): + with pytest.raises(TypeError, match="async functions"): + + @step # pyright: ignore[reportArgumentType] + def not_async(x: int) -> int: # pyright: ignore[reportUnusedFunction] + return x + + +# --------------------------------------------------------------------------- +# WorkflowInterrupted is BaseException +# --------------------------------------------------------------------------- + + +class TestWorkflowInterruptedIsBaseException: + async def test_except_exception_does_not_catch_interrupt(self): + """User code with ``except Exception`` should not catch WorkflowInterrupted.""" + caught = False + + @workflow + async def wf(x: int, ctx: RunContext) -> str: + nonlocal caught + try: + return await ctx.request_info("need review", response_type=str, request_id="r1") + except Exception: + # This should NOT catch WorkflowInterrupted + caught = True + return "caught!" + + result = await wf.run("data") + # Should have a pending request, NOT "caught!" + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + assert result.get_outputs() == [] + assert caught is False + + +# --------------------------------------------------------------------------- +# Checkpoint validation +# --------------------------------------------------------------------------- + + +class TestCheckpointValidation: + async def test_checkpoint_signature_mismatch_raises(self): + from agent_framework import WorkflowCheckpoint + + storage = InMemoryCheckpointStorage() + + @workflow(name="my_wf", checkpoint_storage=storage) + async def wf(x: int) -> int: + return x + + # Manually create a checkpoint with a different signature hash + bad_checkpoint = WorkflowCheckpoint( + workflow_name="my_wf", + graph_signature_hash="totally_different_hash", + state={"_step_cache": {}, "_original_message": 1}, + ) + ckpt_id = await storage.save(bad_checkpoint) + + # Should fail due to hash mismatch + with pytest.raises(ValueError, match="not compatible"): + await wf.run(checkpoint_id=ckpt_id) + + async def test_import_step_cache_malformed_key(self): + ctx = _RunContext("test") + with pytest.raises(ValueError, match="Corrupted step cache"): + ctx._import_step_cache({"invalid_key_no_separator": 42}) # pyright: ignore[reportPrivateUsage] + + async def test_import_step_cache_non_integer_index(self): + ctx = _RunContext("test") + with pytest.raises(ValueError, match="Corrupted step cache"): + ctx._import_step_cache({"step_name::abc": 42}) # pyright: ignore[reportPrivateUsage] + + +# --------------------------------------------------------------------------- +# executor_bypassed event on replay (review comment #3) +# --------------------------------------------------------------------------- + + +class TestExecutorBypassed: + async def test_cached_step_emits_bypassed_event(self): + """When a step replays from cache, it should emit executor_bypassed.""" + storage = InMemoryCheckpointStorage() + call_count = 0 + + @step + async def tracked(x: int) -> int: + nonlocal call_count + call_count += 1 + return x + 1 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await tracked(x) + + # First run — live execution + result1 = await wf.run(5) + assert result1.get_outputs() == [6] + assert call_count == 1 + + event_types1 = [e.type for e in result1] + assert "executor_invoked" in event_types1 + assert "executor_completed" in event_types1 + assert "executor_bypassed" not in event_types1 + + # Restore from checkpoint — cached replay + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + result2 = await wf.run(checkpoint_id=ckpt_id) + assert result2.get_outputs() == [6] + assert call_count == 1 # not called again + + event_types2 = [e.type for e in result2] + assert "executor_bypassed" in event_types2 + # Should NOT have the live-execution pair + assert "executor_invoked" not in event_types2 + assert "executor_completed" not in event_types2 + + async def test_bypassed_event_carries_cached_data(self): + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x * 10 + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> int: + return await compute(x) + + await wf.run(3) + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + + result = await wf.run(checkpoint_id=ckpt_id) + bypassed = [e for e in result if e.type == "executor_bypassed"] + assert len(bypassed) == 1 + assert bypassed[0].executor_id == "compute" + assert bypassed[0].data == 30 + + +# --------------------------------------------------------------------------- +# request_info inside @step (review comment #1) +# --------------------------------------------------------------------------- + + +class TestRequestInfoInStep: + async def test_step_with_run_context_injection(self): + """A @step function with a RunContext parameter gets it auto-injected.""" + + @step + async def review_step(doc: str, ctx: RunContext) -> str: + feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="s1") + return f"reviewed: {feedback}" + + @workflow + async def wf(doc: str) -> str: + return await review_step(doc) + + # Phase 1: should interrupt + result1 = await wf.run("my doc") + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + assert len(result1.get_request_info_events()) == 1 + assert result1.get_request_info_events()[0].request_id == "s1" + + # Phase 2: resume + result2 = await wf.run(responses={"s1": "LGTM"}) + assert result2.get_outputs() == ["reviewed: LGTM"] + + async def test_step_works_outside_workflow_with_explicit_ctx(self): + """Outside a workflow, the step is transparent — caller provides ctx.""" + + @step + async def needs_ctx(data: str, ctx: RunContext) -> str: + val = ctx.get_state("key", "default") + return f"{data}:{val}" + + # Outside a workflow, pass through directly — caller supplies ctx + ctx = RunContext("test") + ctx.set_state("key", "hello") + result = await needs_ctx("data", ctx) + assert result == "data:hello" + + async def test_step_injects_ctx_before_user_positional_parameters(self): + """RunContext injection should not conflict when ctx is the first step parameter.""" + + @step + async def needs_ctx_first(ctx: RunContext, data: str) -> str: + ctx.set_state("seen", data) + return f"{data}:{ctx.get_state('seen')}" + + @workflow + async def wf(data: str) -> str: + return await needs_ctx_first(data) + + result = await wf.run("draft") + + assert result.get_outputs() == ["draft:draft"] + + async def test_get_run_context_inside_workflow(self): + """get_run_context() returns the active RunContext inside a workflow.""" + from agent_framework import get_run_context + + captured_ctx = None + + @step + async def capture_ctx(x: int) -> int: + nonlocal captured_ctx + captured_ctx = get_run_context() + return x + + @workflow + async def wf(x: int) -> int: + return await capture_ctx(x) + + await wf.run(1) + assert captured_ctx is not None + assert isinstance(captured_ctx, RunContext) + + async def test_get_run_context_outside_workflow(self): + """get_run_context() returns None outside a workflow.""" + from agent_framework import get_run_context + + assert get_run_context() is None + + +# --------------------------------------------------------------------------- +# None response handling (review comment #2) +# --------------------------------------------------------------------------- + + +class TestNoneResponseHandling: + async def test_none_response_logs_warning(self): + """Providing None as a response value should log a warning.""" + + @workflow + async def wf(doc: str, ctx: RunContext) -> str: + val = await ctx.request_info("need input", response_type=str, request_id="r1") + return f"got: {val}" + + # Phase 1 + await wf.run("start") + + # Phase 2: resume with None response — should warn but still work + with caplog_context(logging.getLogger("agent_framework._workflows._functional")) as logs: + result = await wf.run(responses={"r1": None}) + + assert result.get_outputs() == ["got: None"] + assert any("None" in msg and "r1" in msg for msg in logs) + + async def test_none_response_is_returned(self): + """None is a valid (if discouraged) response value.""" + + @workflow + async def wf(x: int, ctx: RunContext) -> str: + val = await ctx.request_info("need data", response_type=str, request_id="r1") + return f"value={val}" + + await wf.run(1) + result = await wf.run(responses={"r1": None}) + assert result.get_outputs() == ["value=None"] + + +# Helper for capturing log messages + + +@contextmanager +def caplog_context(target_logger: logging.Logger) -> Iterator[list[str]]: + """Capture log messages from a specific logger.""" + messages: list[str] = [] + + class _Handler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + messages.append(self.format(record)) + + handler = _Handler() + handler.setLevel(logging.WARNING) + target_logger.addHandler(handler) + try: + yield messages + finally: + target_logger.removeHandler(handler) + + +# --------------------------------------------------------------------------- +# Combined regression tests (cross-cutting review comments #1, #2, #3) +# --------------------------------------------------------------------------- + + +class TestHITLInStepWithCaching: + """Regression tests: request_info inside @step combined with caching and bypass.""" + + async def test_preceding_step_bypassed_on_hitl_resume(self): + """When a step after a completed step calls request_info and interrupts, + resuming should bypass the first step (cached) and re-execute the HITL step.""" + call_count_a = 0 + + @step + async def step_a(x: int) -> int: + nonlocal call_count_a + call_count_a += 1 + return x + 1 + + @step + async def step_b(val: int, ctx: RunContext) -> str: + feedback = await ctx.request_info({"val": val}, response_type=str, request_id="r1") + return f"{val}:{feedback}" + + @workflow + async def wf(x: int) -> str: + a = await step_a(x) + return await step_b(a) + + # Phase 1: step_a completes, step_b interrupts + result1 = await wf.run(5) + assert call_count_a == 1 + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume — step_a should be bypassed, step_b re-executes + result2 = await wf.run(responses={"r1": "ok"}) + assert call_count_a == 1 # step_a not called again + assert result2.get_outputs() == ["6:ok"] + + event_types = [e.type for e in result2] + assert "executor_bypassed" in event_types + + async def test_hitl_step_with_checkpoint_full_lifecycle(self): + """Full lifecycle: run -> interrupt -> resume -> checkpoint restore -> all bypassed.""" + storage = InMemoryCheckpointStorage() + + @step + async def compute(x: int) -> int: + return x * 10 + + @step + async def review(val: int, ctx: RunContext) -> str: + feedback = await ctx.request_info({"val": val}, response_type=str, request_id="rev") + return f"reviewed({val}):{feedback}" + + @workflow(checkpoint_storage=storage) + async def wf(x: int) -> str: + v = await compute(x) + return await review(v) + + # Phase 1: interrupt + result1 = await wf.run(3) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + # Phase 2: resume + result2 = await wf.run(responses={"rev": "LGTM"}) + assert result2.get_outputs() == ["reviewed(30):LGTM"] + + # Phase 3: restore from latest checkpoint -- both steps should be bypassed + ckpt_id = (await storage.list_checkpoints(workflow_name="wf"))[-1].checkpoint_id + result3 = await wf.run(checkpoint_id=ckpt_id) + assert result3.get_outputs() == ["reviewed(30):LGTM"] + + event_types3 = [e.type for e in result3] + bypassed = [e for e in result3 if e.type == "executor_bypassed"] + assert len(bypassed) == 2 + assert "executor_invoked" not in event_types3 + + async def test_none_response_in_step_request_info(self): + """None response inside a @step request_info should warn and return None.""" + + @step + async def needs_feedback(doc: str, ctx: RunContext) -> str: + val = await ctx.request_info({"doc": doc}, response_type=str, request_id="r1") + return f"got:{val}" + + @workflow + async def wf(doc: str) -> str: + return await needs_feedback(doc) + + await wf.run("draft") + + with caplog_context(logging.getLogger("agent_framework._workflows._functional")) as logs: + result = await wf.run(responses={"r1": None}) + + assert result.get_outputs() == ["got:None"] + assert any("None" in msg and "r1" in msg for msg in logs) + + async def test_step_hitl_does_not_emit_executor_failed(self): + """WorkflowInterrupted from request_info inside a step should NOT emit executor_failed.""" + + @step + async def hitl_step(x: int, ctx: RunContext) -> str: + return await ctx.request_info("need data", response_type=str, request_id="r1") + + @workflow + async def wf(x: int) -> str: + return await hitl_step(x) + + result = await wf.run(1) + event_types = [e.type for e in result] + assert "executor_failed" not in event_types + assert result.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + +# --------------------------------------------------------------------------- +# Regression tests for ultrareview findings +# --------------------------------------------------------------------------- + + +class TestDeterministicAutoRequestId: + """Regression for bug_001: auto-generated request_info ids must be stable across replay.""" + + async def test_auto_request_id_roundtrips_on_resume(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + # No request_id — framework must generate a deterministic one + val = await ctx.request_info("need data", response_type=str) + return f"got:{val}" + + result1 = await wf.run(1) + assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + requests = result1.get_request_info_events() + assert len(requests) == 1 + rid = requests[0].request_id + assert rid # non-empty + + # Resume with the id the caller just received. + result2 = await wf.run(responses={rid: "hello"}) + assert result2.get_final_state() == WorkflowRunState.IDLE + assert result2.get_outputs() == ["got:hello"] + + async def test_multiple_auto_ids_are_distinct_and_stable(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + a = await ctx.request_info("first", response_type=str) + b = await ctx.request_info("second", response_type=str) + return f"{a}/{b}" + + r1 = await wf.run(1) + rid1 = r1.get_request_info_events()[0].request_id + r2 = await wf.run(responses={rid1: "A"}) + rid2 = r2.get_request_info_events()[0].request_id + assert rid1 != rid2 + r3 = await wf.run(responses={rid1: "A", rid2: "B"}) + assert r3.get_outputs() == ["A/B"] + + async def test_cached_step_advances_auto_request_id_counter(self): + call_count = 0 + + @step + async def first_review(value: int, ctx: RunContext) -> str: + nonlocal call_count + call_count += 1 + return await ctx.request_info({"step": "first", "value": value}, response_type=str) + + @step + async def second_review(value: int, ctx: RunContext) -> str: + return await ctx.request_info({"step": "second", "value": value}, response_type=str) + + @workflow + async def wf(value: int) -> str: + first = await first_review(value) + second = await second_review(value) + return f"{first}/{second}" + + first_run = await wf.run(1) + first_request_id = first_run.get_request_info_events()[0].request_id + assert first_request_id == "auto::0" + + second_run = await wf.run(responses={first_request_id: "A"}) + second_request_id = second_run.get_request_info_events()[0].request_id + assert second_request_id == "auto::1" + completed_call_count = call_count + + final_run = await wf.run(responses={first_request_id: "A", second_request_id: "B"}) + + assert call_count == completed_call_count + assert final_run.get_outputs() == ["A/B"] + + +class TestPendingRequestsPruned: + """Regression for bug_007: resolved requests must be pruned from _pending_requests.""" + + async def test_final_checkpoint_no_longer_claims_resolved_requests_pending(self): + storage = InMemoryCheckpointStorage() + + @workflow(checkpoint_storage=storage) + async def wf(x: int, ctx: RunContext) -> str: + a = await ctx.request_info("q1", response_type=str, request_id="r1") + b = await ctx.request_info("q2", response_type=str, request_id="r2") + return f"{a}/{b}" + + await wf.run(1) + await wf.run(responses={"r1": "A"}) + result = await wf.run(responses={"r1": "A", "r2": "B"}) + assert result.get_final_state() == WorkflowRunState.IDLE + # Latest checkpoint must show no pending requests. + checkpoints = await storage.list_checkpoints(workflow_name="wf") + assert checkpoints, "expected at least one checkpoint to have been saved" + final = checkpoints[-1] + assert final.pending_request_info_events == {} + + +class TestArityValidation: + """Regression for merged_bug_003: validate workflow signature arity.""" + + def test_multi_non_ctx_param_rejected_at_decoration(self): + with pytest.raises(ValueError, match="multiple non-RunContext parameters"): + + @workflow + async def wf(a: str, b: str, ctx: RunContext) -> str: + return f"{a}+{b}" + + async def test_ctx_only_workflow_with_message_raises_clear_error(self): + @workflow + async def wf(ctx: RunContext) -> str: + return "no message used" + + with pytest.raises(ValueError, match="no non-RunContext parameter"): + await wf.run("important input") + + def test_ctx_only_workflow_decoration_succeeds(self): + # Decoration must not raise even though the workflow has no + # message-receiving parameter. (Running it without a message still + # requires providing responses or a checkpoint_id — that's + # _validate_run_params's job, not ours.) + @workflow + async def wf(ctx: RunContext) -> str: + return "ok" + + assert wf is not None + + +class TestStaleResponsesRejected: + """Regression for bug_014: stale responses after clean completion must be rejected.""" + + async def test_responses_after_clean_completion_raise(self): + @workflow + async def wf(x: int) -> int: + return x * 2 + + await wf.run(5) # clean completion, no pending requests + with pytest.raises(ValueError, match="no pending request_info"): + await wf.run(responses={"stale": "x"}) + + async def test_responses_mismatched_key_raises(self): + @workflow + async def wf(x: int, ctx: RunContext) -> str: + return await ctx.request_info("q", response_type=str, request_id="r1") + + await wf.run(1) # interrupts with r1 pending + with pytest.raises(ValueError, match="do not answer"): + await wf.run(responses={"definitely_not_r1": "x"}) + + +class TestReservedStateKeys: + """Regression for bug_017: set_state must reject underscore-prefixed keys.""" + + async def test_underscore_key_rejected(self): + @workflow + async def wf(x: int, ctx: RunContext) -> int: + ctx.set_state("_private", "user value") + return x + + with pytest.raises(ValueError, match="reserved for framework"): + await wf.run(1) + + async def test_normal_key_still_works(self): + @workflow + async def wf(x: int, ctx: RunContext) -> int: + ctx.set_state("normal_key", "v") + assert ctx.get_state("normal_key") == "v" + return x + + r = await wf.run(1) + assert r.get_outputs() == [1] + + +class TestDeepcopyOnCacheHit: + """Regression for bug_002: cache hits must not deepcopy args.""" + + async def test_step_with_non_deepcopyable_arg_replays(self): + import threading + + @step + async def takes_lock(lock: threading.Lock, n: int) -> int: + return n + 1 + + @workflow + async def wf(x: int) -> int: + lock = threading.Lock() + return await takes_lock(lock, x) + + # First run — must succeed despite threading.Lock not being deepcopyable + # (deepcopy now wrapped in try/except, falls back to live reference for + # the invocation_data event only). + r1 = await wf.run(5) + assert r1.get_outputs() == [6] + + +class TestStepDiscoveryAttributeAccess: + """Regression for bug_008: checkpoint hash must differ when function body changes.""" + + async def test_signature_hash_changes_when_function_body_changes(self): + @workflow + async def wf_a(x: int) -> int: + return x + 1 + + @workflow(name="wf_b") + async def wf_b(x: int) -> int: + return x * 100 + + # Two different function bodies -> different hashes even though the + # static step-name scan would produce the same empty list. + assert wf_a.graph_signature_hash != wf_b.graph_signature_hash + + +class TestAsAgentSignatureParity: + """Regression for bug_015: as_agent signature must accept description/context_providers.""" + + async def test_as_agent_accepts_description_override(self): + @workflow(description="workflow level") + async def wf(x: str) -> str: + return x.upper() + + agent = wf.as_agent(name="a", description="agent level") + assert agent.description == "agent level" + + async def test_as_agent_accepts_context_providers_kwarg(self): + @workflow + async def wf(x: str) -> str: + return x + + providers = [object()] # opaque placeholder; must be stored without error + agent = wf.as_agent(context_providers=providers) + assert list(agent.context_providers or []) == providers + + async def test_as_agent_description_defaults_to_workflow_description(self): + @workflow(description="from workflow") + async def wf(x: str) -> str: + return x + + agent = wf.as_agent() + assert agent.description == "from workflow" + + +class TestFunctionalWorkflowAgentHITL: + """Regression for bug_013: .as_agent() must surface request_info events.""" + + async def test_request_info_surfaces_as_function_approval_request(self): + @workflow + async def wf(x: str, ctx: RunContext) -> str: + answer = await ctx.request_info({"need": x}, response_type=str, request_id="rid-1") + return f"got:{answer}" + + agent = wf.as_agent() + response = await agent.run("topic") + + # Agent must expose the pending request_id. + assert "rid-1" in agent.pending_requests + + # Response must contain at least one content item whose type is + # function_approval_request (or equivalent). + approval_found = False + for message in response.messages: + for content in message.contents: + if getattr(content, "type", None) == "function_approval_request": + approval_found = True + break + assert approval_found, "expected FunctionApprovalRequestContent in agent response" + + async def test_resume_via_agent_responses_kwarg(self): + @workflow + async def wf(x: str, ctx: RunContext) -> str: + answer = await ctx.request_info(x, response_type=str, request_id="rid-1") + return f"got:{answer}" + + agent = wf.as_agent() + # First phase: suspend + await agent.run("topic") + # Second phase: resume via the agent surface + response = await agent.run(responses={"rid-1": "answered"}) + # Agent's final response should contain the workflow's text output. + text_blobs: list[str] = [] + for message in response.messages: + for content in message.contents: + text = getattr(content, "text", None) + if text: + text_blobs.append(text) + assert any("got:answered" in t for t in text_blobs) + + +class TestRunDocstringAllowsResponsesAndCheckpoint: + """Regression for bug_010: docstring must permit responses+checkpoint_id combo.""" + + def test_docstring_says_at_least_one(self): + doc = FunctionalWorkflow.run.__doc__ or "" + assert "At least one" in doc or "at least one" in doc + assert "Exactly one" not in doc + + +class TestFunctionalWorkflowExperimentalStage: + """Tests for the experimental stage annotations applied to functional workflow APIs.""" + + def test_public_symbols_are_marked_experimental(self) -> None: + symbols = [ + get_run_context, + RunContext, + StepWrapper, + step, + FunctionalWorkflow, + workflow, + FunctionalWorkflowAgent, + ] + + for symbol in symbols: + assert symbol.__feature_stage__ == "experimental" + assert symbol.__feature_id__ == ExperimentalFeature.FUNCTIONAL_WORKFLOWS.value + assert symbol.__doc__ is not None + assert ".. warning:: Experimental" in symbol.__doc__ diff --git a/python/samples/01-get-started/05_functional_workflow_with_agents.py b/python/samples/01-get-started/05_functional_workflow_with_agents.py new file mode 100644 index 0000000000..05e5f2637f --- /dev/null +++ b/python/samples/01-get-started/05_functional_workflow_with_agents.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Functional Workflow with Agents — Call agents inside @workflow + +This sample shows how to call agents inside a functional workflow. +Agent calls are just regular async function calls — no special wrappers needed. +""" + +import asyncio + +from agent_framework import Agent, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# +client = FoundryChatClient(credential=AzureCliCredential()) + +writer = Agent( + name="WriterAgent", + instructions="Write a short poem (4 lines max) about the given topic.", + client=client, +) + +reviewer = Agent( + name="ReviewerAgent", + instructions="Review the given poem in one sentence. Is it good?", + client=client, +) +# + + +# +@workflow +async def poem_workflow(topic: str) -> str: + """Write a poem, then review it.""" + poem = (await writer.run(f"Write a poem about: {topic}")).text + review = (await reviewer.run(f"Review this poem: {poem}")).text + return f"Poem:\n{poem}\n\nReview: {review}" +# + + +async def main() -> None: + result = await poem_workflow.run("a cat learning to code") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/06_functional_workflow_basics.py b/python/samples/01-get-started/06_functional_workflow_basics.py new file mode 100644 index 0000000000..8033a7ac4e --- /dev/null +++ b/python/samples/01-get-started/06_functional_workflow_basics.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +""" +Functional Workflow Basics — Orchestrate async functions with @workflow + +The functional API lets you write workflows as plain Python async functions. +No graph concepts, no edges, no executor classes — just call functions +and use native control flow (if/else, loops, asyncio.gather). + +This sample builds a minimal pipeline with two steps: +1. Convert text to uppercase +2. Reverse the text + +No external services are required. +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — no decorators needed +async def to_upper_case(text: str) -> str: + """Convert input to uppercase.""" + return text.upper() + + +async def reverse_text(text: str) -> str: + """Reverse the string.""" + return text[::-1] + + +# +@workflow +async def text_workflow(text: str) -> str: + """Uppercase the text, then reverse it.""" + upper = await to_upper_case(text) + return await reverse_text(upper) +# + + +async def main() -> None: + # + result = await text_workflow.run("hello world") + print(f"Output: {result.get_outputs()}") + print(f"Final state: {result.get_final_state()}") + # + + """ + Expected output: + Output: ['DLROW OLLEH'] + Final state: WorkflowRunState.IDLE + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/01-get-started/05_first_workflow.py b/python/samples/01-get-started/07_first_graph_workflow.py similarity index 87% rename from python/samples/01-get-started/05_first_workflow.py rename to python/samples/01-get-started/07_first_graph_workflow.py index 74720e529f..2a84fabfab 100644 --- a/python/samples/01-get-started/05_first_workflow.py +++ b/python/samples/01-get-started/07_first_graph_workflow.py @@ -12,9 +12,12 @@ from agent_framework import ( from typing_extensions import Never """ -First Workflow — Chain executors with edges +First Graph Workflow — Chain executors with edges -This sample builds a minimal workflow with two steps: +The graph API gives you full control over execution topology: edges, +fan-out/fan-in, switch/case, and superstep-based checkpointing. + +This sample builds a minimal graph workflow with two steps: 1. Convert text to uppercase (class-based executor) 2. Reverse the text (function-based executor) diff --git a/python/samples/01-get-started/06_host_your_agent.py b/python/samples/01-get-started/08_host_your_agent.py similarity index 100% rename from python/samples/01-get-started/06_host_your_agent.py rename to python/samples/01-get-started/08_host_your_agent.py diff --git a/python/samples/01-get-started/README.md b/python/samples/01-get-started/README.md index 9ecfdf08c9..61aefc6592 100644 --- a/python/samples/01-get-started/README.md +++ b/python/samples/01-get-started/README.md @@ -24,8 +24,10 @@ export FOUNDRY_MODEL="gpt-4o" # optional, defaults to gpt-4o | 2 | [02_add_tools.py](02_add_tools.py) | Define a function tool with `@tool` and attach it to an agent. | | 3 | [03_multi_turn.py](03_multi_turn.py) | Keep conversation history across turns with `AgentSession`. | | 4 | [04_memory.py](04_memory.py) | Add dynamic context with a custom `ContextProvider`. | -| 5 | [05_first_workflow.py](05_first_workflow.py) | Chain executors into a workflow with edges. | -| 6 | [06_host_your_agent.py](06_host_your_agent.py) | Host a single agent with Azure Functions. | +| 5 | [05_functional_workflow_with_agents.py](05_functional_workflow_with_agents.py) | Call agents inside a functional workflow. | +| 6 | [06_functional_workflow_basics.py](06_functional_workflow_basics.py) | Write a workflow as a plain async function. | +| 7 | [07_first_graph_workflow.py](07_first_graph_workflow.py) | Chain executors into a graph workflow with edges. | +| 8 | [08_host_your_agent.py](08_host_your_agent.py) | Host a single agent with Azure Functions. | Run any sample with: diff --git a/python/samples/03-workflows/README.md b/python/samples/03-workflows/README.md index ae3292a07a..15d224563f 100644 --- a/python/samples/03-workflows/README.md +++ b/python/samples/03-workflows/README.md @@ -30,6 +30,20 @@ Once comfortable with these, explore the rest of the samples below. ## Samples Overview (by directory) +### functional + +Write workflows as plain Python async functions — no graph concepts, no executor classes, no edges. Use native control flow (`if`/`else`, loops, `asyncio.gather`) for branching and parallelism. + +| Sample | File | Concepts | +|---|---|---| +| Basic Pipeline | [functional/basic_pipeline.py](./functional/basic_pipeline.py) | Sequential steps as plain async functions | +| Basic Streaming Pipeline | [functional/basic_streaming_pipeline.py](./functional/basic_streaming_pipeline.py) | Stream workflow events in real time with `run(stream=True)` | +| Parallel Pipeline | [functional/parallel_pipeline.py](./functional/parallel_pipeline.py) | Fan-out/fan-in with `asyncio.gather` | +| Steps and Checkpointing | [functional/steps_and_checkpointing.py](./functional/steps_and_checkpointing.py) | `@step` decorator for per-step checkpointing and observability | +| Human-in-the-Loop Review | [functional/hitl_review.py](./functional/hitl_review.py) | HITL with `ctx.request_info()` and replay | +| Agent Integration | [functional/agent_integration.py](./functional/agent_integration.py) | Calling agents inside workflow steps | +| Naive Group Chat | [functional/naive_group_chat.py](./functional/naive_group_chat.py) | Simple round-robin group chat as a plain loop | + ### agents | Sample | File | Concepts | diff --git a/python/samples/03-workflows/functional/agent_integration.py b/python/samples/03-workflows/functional/agent_integration.py new file mode 100644 index 0000000000..b5911cb690 --- /dev/null +++ b/python/samples/03-workflows/functional/agent_integration.py @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Calling agents inside functional workflows. + +Agent calls work inside @workflow as plain function calls — no decorator needed. +Just call the agent and use the result. + +If you want per-step caching (so agent calls don't re-execute on HITL resume +or crash recovery), add @step. Since each agent call hits an LLM API (time + +money), @step is often worth it. But it's always opt-in. + +This sample shows both approaches side-by-side so you can see the difference. +""" + +import asyncio + +from agent_framework import Agent, step, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# --------------------------------------------------------------------------- +# Create agents +# --------------------------------------------------------------------------- + +client = FoundryChatClient(credential=AzureCliCredential()) + +classifier_agent = Agent( + name="ClassifierAgent", + instructions=( + "Classify documents into one category: Technical, Legal, Marketing, or Scientific. " + "Reply with only the category name." + ), + client=client, +) + +writer_agent = Agent( + name="WriterAgent", + instructions="Summarize the given content in one sentence.", + client=client, +) + +reviewer_agent = Agent( + name="ReviewerAgent", + instructions="Review the given summary in one sentence. Is it accurate and complete?", + client=client, +) + +# --------------------------------------------------------------------------- +# Simplest approach: call agents directly inside the workflow. +# No @step, no wrappers — just plain function calls. +# --------------------------------------------------------------------------- + + +@workflow +async def simple_pipeline(document: str) -> str: + """Process a document — agents called inline, no @step.""" + classification = (await classifier_agent.run(f"Classify this document: {document}")).text + summary = (await writer_agent.run(f"Summarize: {document}")).text + review = (await reviewer_agent.run(f"Review this summary: {summary}")).text + + return f"Classification: {classification}\nSummary: {summary}\nReview: {review}" + + +# --------------------------------------------------------------------------- +# With @step: agent results are cached. On HITL resume or checkpoint +# recovery, completed steps return their saved result instead of calling +# the LLM again. Worth it for expensive operations. +# --------------------------------------------------------------------------- + + +@step +async def classify_document(doc: str) -> str: + return (await classifier_agent.run(f"Classify this document: {doc}")).text + + +@step +async def generate_summary(doc: str) -> str: + return (await writer_agent.run(f"Summarize: {doc}")).text + + +@step +async def review_summary(summary: str) -> str: + return (await reviewer_agent.run(f"Review this summary: {summary}")).text + + +@workflow +async def cached_pipeline(document: str) -> str: + """Same pipeline, but @step caches each agent call.""" + classification = await classify_document(document) + summary = await generate_summary(document) + review = await review_summary(summary) + + return f"Classification: {classification}\nSummary: {summary}\nReview: {review}" + + +async def main(): + # Simple version — agents called inline + result = await simple_pipeline.run("This is a technical document about machine learning...") + print(result.get_outputs()[0]) + + # Cached version — same result, but steps won't re-execute on resume + result = await cached_pipeline.run("This is a technical document about machine learning...") + print(f"\nCached: {result.get_outputs()[0]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/basic_pipeline.py b/python/samples/03-workflows/functional/basic_pipeline.py new file mode 100644 index 0000000000..81514da53a --- /dev/null +++ b/python/samples/03-workflows/functional/basic_pipeline.py @@ -0,0 +1,58 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic sequential pipeline using the functional workflow API. + +The simplest possible workflow: plain async functions orchestrated by @workflow. +No @step decorator needed — just write Python. +""" + +import asyncio + +from agent_framework import workflow + + +# These are plain async functions — no decorators needed. +# They run normally inside the workflow, just like any other Python function. +async def fetch_data(url: str) -> dict[str, str | int]: + """Simulate fetching data from a URL.""" + return {"url": url, "content": f"Data from {url}", "status": 200} + + +async def transform_data(data: dict[str, str | int]) -> str: + """Transform raw data into a summary string.""" + return f"[{data['status']}] {data['content']}" + + +# @workflow turns this async function into a FunctionalWorkflow object. +# Without it, this is just a normal async function. With it, you get: +# - .run() that returns a WorkflowRunResult with events and outputs +# - .run(stream=True) for streaming events in real time +# - .as_agent() to use this workflow anywhere an agent is expected +# +# The function's first parameter receives the input from .run("..."). +# Add a `ctx: RunContext` parameter only if you need HITL, state, or custom events. +@workflow +async def data_pipeline(url: str) -> str: + """A simple sequential data pipeline.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + + # This is just a function — plain Python works between calls. + # No need to wrap every operation in a separate async function. + is_valid = len(summary) > 0 and "[200]" in summary + tag = "VALID" if is_valid else "INVALID" + + # Returning a value automatically emits it as an output. + # Callers retrieve it via result.get_outputs(). + return f"[{tag}] {summary}" + + +async def main(): + # .run() is provided by @workflow — a plain async function wouldn't have it + result = await data_pipeline.run("https://example.com/api/data") + print("Output:", result.get_outputs()[0]) + print("State:", result.get_final_state()) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/basic_streaming_pipeline.py b/python/samples/03-workflows/functional/basic_streaming_pipeline.py new file mode 100644 index 0000000000..4ee61da60f --- /dev/null +++ b/python/samples/03-workflows/functional/basic_streaming_pipeline.py @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Basic streaming pipeline using the functional workflow API. + +Stream workflow events in real time with run(stream=True). +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — no decorators needed for simple helpers. +async def fetch_data(url: str) -> dict[str, str | int]: + """Simulate fetching data from a URL.""" + return {"url": url, "content": f"Data from {url}", "status": 200} + + +async def transform_data(data: dict[str, str | int]) -> str: + """Transform raw data into a summary string.""" + return f"[{data['status']}] {data['content']}" + + +async def validate_result(summary: str) -> bool: + """Validate the transformed result.""" + return len(summary) > 0 and "[200]" in summary + + +# @workflow enables .run(stream=True), which returns a ResponseStream +# you can iterate over with `async for`. Without @workflow, you'd just +# have a normal async function with no streaming capability. +@workflow +async def data_pipeline(url: str) -> str: + """A simple sequential data pipeline.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + is_valid = await validate_result(summary) + + return f"{summary} (valid={is_valid})" + + +async def main(): + # run(stream=True) returns a ResponseStream that yields events as they + # are produced. The raw stream includes lifecycle events (started, status) + # alongside application events — filter by event.type to find what you need. + stream = data_pipeline.run("https://example.com/api/data", stream=True) + async for event in stream: + if event.type == "output": + print(f"Output: {event.data}") + + # After iteration, get_final_response() returns the WorkflowRunResult + result = await stream.get_final_response() + print(f"Final state: {result.get_final_state()}") + + """ + Expected output: + Output: [200] Data from https://example.com/api/data (valid=True) + Final state: WorkflowRunState.IDLE + """ + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/hitl_review.py b/python/samples/03-workflows/functional/hitl_review.py new file mode 100644 index 0000000000..39f2dae885 --- /dev/null +++ b/python/samples/03-workflows/functional/hitl_review.py @@ -0,0 +1,84 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Human-in-the-loop review pipeline using functional workflows. + +Demonstrates ctx.request_info() for pausing the workflow to wait for +external input and resuming with run(responses={...}). + +HITL works with or without @step. The difference is what happens on resume: +- Without @step: every function re-executes from the top (fine for cheap calls). +- With @step: completed functions return their saved result instantly. + +This sample uses @step on write_draft() because it simulates an expensive +operation that shouldn't re-run just because the workflow was paused. +""" + +import asyncio + +from agent_framework import RunContext, WorkflowRunState, step, workflow + + +# @step saves the result. When the workflow resumes after the HITL pause, +# this returns its saved result instead of running the expensive operation again. +# +# In a real workflow you might call an agent here instead: +# @step +# async def write_draft(topic: str) -> str: +# return (await writer_agent.run(f"Write a draft about: {topic}")).text +@step +async def write_draft(topic: str) -> str: + """Simulate writing a draft — expensive, shouldn't re-run on resume.""" + print(f" write_draft executing for '{topic}'") + return f"Draft document about '{topic}': Lorem ipsum dolor sit amet..." + + +@step +async def revise_draft(draft: str, feedback: str) -> str: + """Revise the draft based on feedback.""" + return f"Revised: {draft[:50]}... [Applied feedback: {feedback}]" + + +@workflow +async def review_pipeline(topic: str, ctx: RunContext) -> str: + """Write a draft, get human review, then revise.""" + draft = await write_draft(topic) + + # ctx.request_info() suspends the workflow here. The caller gets back + # a WorkflowRunResult with state IDLE_WITH_PENDING_REQUESTS and can + # inspect the pending request via result.get_request_info_events(). + feedback = await ctx.request_info( + {"draft": draft, "instructions": "Please review this draft"}, + response_type=str, + request_id="review_request", + ) + + # This only executes after the caller resumes with run(responses={...}). + # write_draft above returns its saved result (thanks to @step), + # request_info returns the provided response, and we continue here. + return await revise_draft(draft, feedback) + + +async def main(): + # Phase 1: Run until the workflow pauses for human input + print("=== Phase 1: Initial run ===") + result1 = await review_pipeline.run("AI Safety") + + # If request_info() was reached, the state is IDLE_WITH_PENDING_REQUESTS. + # If the workflow completed without hitting request_info(), it would be IDLE. + print(f"State: {(final_state := result1.get_final_state())}") + assert final_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS + + requests = result1.get_request_info_events() + print(f"Pending request: {requests[0].request_id}") + + # Phase 2: Resume with the human's response + print("\n=== Phase 2: Resume with feedback ===") + print("(write_draft should NOT execute again — saved by @step)") + result2 = await review_pipeline.run(responses={"review_request": "Add more details about alignment research"}) + + print(f"State: {result2.get_final_state()}") + print(f"Output: {result2.get_outputs()[0]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/naive_group_chat.py b/python/samples/03-workflows/functional/naive_group_chat.py new file mode 100644 index 0000000000..45a3266049 --- /dev/null +++ b/python/samples/03-workflows/functional/naive_group_chat.py @@ -0,0 +1,82 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Naive group chat using the functional workflow API. + +A simple round-robin group chat where agents take turns responding. +Because it's just a function, you control the loop, the turn order, +and the termination condition with plain Python — no framework abstractions. + +Compare this with the graph-based GroupChat orchestration to see how the +functional API lets you start simple and add complexity only when needed. +""" + +import asyncio + +from agent_framework import Agent, Message, workflow +from agent_framework.foundry import FoundryChatClient +from azure.identity import AzureCliCredential + +# --------------------------------------------------------------------------- +# Create agents +# --------------------------------------------------------------------------- + +client = FoundryChatClient(credential=AzureCliCredential()) + +expert = Agent( + name="PythonExpert", + instructions=( + "You are a Python expert in a group discussion. " + "Answer questions about Python and refine your answer based on feedback. " + "Keep responses concise (2-3 sentences)." + ), + client=client, +) + +critic = Agent( + name="Critic", + instructions=( + "You are a constructive critic in a group discussion. " + "Point out edge cases, gotchas, or missing nuances in the previous answer. " + "If the answer is solid, say so briefly." + ), + client=client, +) + +summarizer = Agent( + name="Summarizer", + instructions=( + "You are a summarizer in a group discussion. " + "After the discussion, provide a final concise summary that incorporates " + "the expert's answer and the critic's feedback. Keep it to 2-3 sentences." + ), + client=client, +) + +# --------------------------------------------------------------------------- +# A naive group chat is just a loop — no special framework needed +# --------------------------------------------------------------------------- + + +@workflow +async def group_chat(question: str) -> str: + """Round-robin group chat: expert answers, critic reviews, summarizer wraps up.""" + participants = [expert, critic, summarizer] + # Passing list[Message] keeps roles/authorship intact between turns, + # instead of stringifying everything into a single prompt. + conversation: list[Message] = [Message("user", [question])] + + # Simple round-robin: each agent sees the full conversation so far + for agent in participants: + response = await agent.run(conversation) + conversation.extend(response.messages) + + return "\n\n".join(f"{m.author_name or m.role}: {m.text}" for m in conversation) + + +async def main(): + result = await group_chat.run("What's the difference between a list and a tuple in Python?") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/parallel_pipeline.py b/python/samples/03-workflows/functional/parallel_pipeline.py new file mode 100644 index 0000000000..21fd8dceac --- /dev/null +++ b/python/samples/03-workflows/functional/parallel_pipeline.py @@ -0,0 +1,66 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Parallel pipeline using asyncio.gather with functional workflows. + +Fan-out/fan-in uses native Python concurrency via asyncio.gather. +No @step needed — still just plain async functions. +""" + +import asyncio + +from agent_framework import workflow + + +# Plain async functions — asyncio.gather handles the concurrency, +# no framework primitives needed for parallelism. +async def research_web(topic: str) -> str: + """Simulate web research.""" + await asyncio.sleep(0.05) + return f"Web results for '{topic}': 10 articles found" + + +async def research_papers(topic: str) -> str: + """Simulate academic paper search.""" + await asyncio.sleep(0.05) + return f"Papers on '{topic}': 3 relevant papers" + + +async def research_news(topic: str) -> str: + """Simulate news search.""" + await asyncio.sleep(0.05) + return f"News about '{topic}': 5 recent articles" + + +async def synthesize(sources: list[str]) -> str: + """Combine research results into a summary.""" + return "Research Summary:\n" + "\n".join(f" - {s}" for s in sources) + + +# @workflow wraps the orchestration logic so you get .run(), streaming, +# and events. The functions it calls are plain Python — no decorators +# needed just because they're inside a workflow. +@workflow +async def research_pipeline(topic: str) -> str: + """Fan-out to three research tasks, then synthesize results.""" + # asyncio.gather runs all three concurrently — this is standard Python, + # not a framework concept. Use it the same way you would anywhere else. + # + # Tip: if any of these were wrapped with @step (e.g. an expensive agent call), + # the pattern is identical — @step composes with asyncio.gather, so each + # branch is independently cached on HITL resume or checkpoint restore. + web, papers, news = await asyncio.gather( + research_web(topic), + research_papers(topic), + research_news(topic), + ) + + return await synthesize([web, papers, news]) + + +async def main(): + result = await research_pipeline.run("AI agents") + print(result.get_outputs()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/03-workflows/functional/steps_and_checkpointing.py b/python/samples/03-workflows/functional/steps_and_checkpointing.py new file mode 100644 index 0000000000..93a9423df0 --- /dev/null +++ b/python/samples/03-workflows/functional/steps_and_checkpointing.py @@ -0,0 +1,97 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Introducing @step: per-step checkpointing and observability. + +The previous samples used plain functions — and that works. Workflows support +HITL (ctx.request_info) and checkpointing regardless of whether you use @step. + +The difference: without @step, a resumed workflow re-executes every function +call from the top. That's fine for cheap functions. But for expensive operations +(API calls, agent runs, etc.) you don't want to pay that cost again. + +@step saves each function's result so it skips re-execution on resume: +- On HITL resume, completed steps return their saved result instantly. +- On crash recovery from a checkpoint, earlier step results are restored. +- Each step emits executor_invoked/executor_completed events for observability. + +@step is opt-in. Plain functions still work alongside @step in the same workflow. +""" + +import asyncio + +from agent_framework import InMemoryCheckpointStorage, step, workflow + +# Track call counts to show which functions actually execute on resume +fetch_calls = 0 +transform_calls = 0 + + +# @step saves this function's result. On resume, it returns the saved +# result instead of re-executing — useful because this is expensive. +@step +async def fetch_data(url: str) -> dict[str, str | int]: + """Expensive operation — @step prevents re-execution on resume.""" + global fetch_calls + fetch_calls += 1 + print(f" fetch_data called (call #{fetch_calls})") + return {"url": url, "content": f"Data from {url}", "status": 200} + + +@step +async def transform_data(data: dict[str, str | int]) -> str: + """Another expensive operation — @step saves the result.""" + global transform_calls + transform_calls += 1 + print(f" transform_data called (call #{transform_calls})") + return f"[{data['status']}] {data['content']}" + + +# No @step — this is cheap, so it just re-runs on resume. That's fine. +async def validate_result(summary: str) -> bool: + """Cheap validation — no @step needed.""" + return len(summary) > 0 and "[200]" in summary + + +storage = InMemoryCheckpointStorage() + + +# checkpoint_storage tells @workflow where to persist step results. +# Each @step saves a checkpoint after it completes. +@workflow(checkpoint_storage=storage) +async def data_pipeline(url: str) -> str: + """Mix of @step functions and plain functions.""" + raw = await fetch_data(url) + summary = await transform_data(raw) + is_valid = await validate_result(summary) + + return f"{summary} (valid={is_valid})" + + +async def main(): + # --- Run 1: Everything executes normally --- + print("=== Run 1: Fresh execution ===") + result = await data_pipeline.run("https://example.com/api/data") + print(f"Output: {result.get_outputs()[0]}") + print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}") + + # @step functions emit executor events; plain functions don't. + print("\nEvents:") + for event in result: + if event.type in ("executor_invoked", "executor_completed"): + print(f" {event.type}: {event.executor_id}") + + # --- Run 2: Restore from checkpoint --- + # The workflow re-executes, but @step functions return saved results. + # Only validate_result() (no @step) actually runs again. + print("\n=== Run 2: Restored from checkpoint ===") + latest = await storage.get_latest(workflow_name="data_pipeline") + assert latest is not None + + result2 = await data_pipeline.run(checkpoint_id=latest.checkpoint_id) + print(f"Output: {result2.get_outputs()[0]}") + print(f"fetch_calls={fetch_calls}, transform_calls={transform_calls}") + print("(call counts unchanged — @step results were restored from checkpoint)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/README.md b/python/samples/README.md index 953d9ff9bb..0ff8563933 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -20,8 +20,10 @@ Start with `01-get-started/` and work through the numbered files: 2. **[02_add_tools.py](./01-get-started/02_add_tools.py)** — Add function tools with `@tool` 3. **[03_multi_turn.py](./01-get-started/03_multi_turn.py)** — Multi-turn conversations with `AgentSession` 4. **[04_memory.py](./01-get-started/04_memory.py)** — Agent memory with `ContextProvider` -5. **[05_first_workflow.py](./01-get-started/05_first_workflow.py)** — Build a workflow with executors and edges -6. **[06_host_your_agent.py](./01-get-started/06_host_your_agent.py)** — Host your agent via Azure Functions +5. **[05_functional_workflow_with_agents.py](./01-get-started/05_functional_workflow_with_agents.py)** — Call agents inside a functional workflow +6. **[06_functional_workflow_basics.py](./01-get-started/06_functional_workflow_basics.py)** — Write a workflow as a plain async function +7. **[07_first_graph_workflow.py](./01-get-started/07_first_graph_workflow.py)** — Build a workflow with executors and edges +8. **[08_host_your_agent.py](./01-get-started/08_host_your_agent.py)** — Host your agent via Azure Functions ## Prerequisites From 7b70f80036bcc9a4138bb321249bd4d7983f803d Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 24 Apr 2026 02:59:14 -0700 Subject: [PATCH 51/52] Python: Surface `oauth_consent_request` events from Responses API in Foundry clients (#5070) * Fix Foundry clients not surfacing oauth_consent_request events (#5054) Override _parse_chunk_from_openai in both RawFoundryChatClient and RawFoundryAgentChatClient to intercept response.output_item.added events with item.type == 'oauth_consent_request'. The consent link is validated (HTTPS required) and converted to Content.from_oauth_consent_request, which the AG-UI layer already knows how to emit as a CUSTOM event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback for #5054 OAuth consent parsing - Extract shared helper (try_parse_oauth_consent_event) to avoid duplicated logic between RawFoundryChatClient and RawFoundryAgentChatClient - Use urllib.parse.urlparse() for HTTPS validation instead of case-sensitive startswith check - Sanitize log messages to avoid leaking consent_link tokens; log only item id - Add model=self.model to ChatResponseUpdate to match parent behavior - Add assertions on role, raw_representation, and model in happy-path tests - Add test for empty-string consent_link - Add test verifying non-oauth events delegate to super() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle response.oauth_consent_requested top-level event (#5054) Add support for the top-level response.oauth_consent_requested stream event in addition to the response.output_item.added variant. The service may emit either form; handle both so the consent link is reliably surfaced. Extract _validate_consent_link helper within _oauth_helpers.py to reduce nesting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Address review feedback: defensive getattr and dedicated helper tests (#5054) - Use getattr(event, 'type', None) in try_parse_oauth_consent_event for defensive access against malformed events without a type attribute - Add test_oauth_helpers.py with unit tests for _validate_consent_link and try_parse_oauth_consent_event covering edge cases: - HTTPS URL with empty netloc (https:///path) - Warning log messages for rejected consent links - Event objects missing 'type' attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * Fix mypy: match _parse_chunk_from_openai signature with superclass Add seen_reasoning_delta_item_ids parameter to _parse_chunk_from_openai overrides in both RawFoundryChatClient and RawFoundryAgentChatClient to match the updated superclass signature on main. Update super() calls and test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson --- .../foundry/agent_framework_foundry/_agent.py | 24 ++- .../agent_framework_foundry/_chat_client.py | 17 ++ .../agent_framework_foundry/_oauth_helpers.py | 75 +++++++ .../tests/foundry/test_foundry_agent.py | 192 +++++++++++++++++- .../tests/foundry/test_foundry_chat_client.py | 163 +++++++++++++++ .../tests/foundry/test_oauth_helpers.py | 164 +++++++++++++++ 6 files changed, 625 insertions(+), 10 deletions(-) create mode 100644 python/packages/foundry/agent_framework_foundry/_oauth_helpers.py create mode 100644 python/packages/foundry/tests/foundry/test_oauth_helpers.py diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index da64b65cd9..fbf165ab21 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -19,6 +19,7 @@ from agent_framework import ( AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponseUpdate, ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -35,6 +36,8 @@ from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): @@ -373,16 +376,19 @@ class RawFoundryAgentChatClient( # type: ignore[misc] options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], seen_reasoning_delta_item_ids: set[str] | None = None, - ) -> Any: - parsed_chunk = super()._parse_chunk_from_openai( - event, - options, - function_call_ids, - seen_reasoning_delta_item_ids, - ) + ) -> ChatResponseUpdate: + """Parse streaming events while preserving hosted-agent session state.""" + update = try_parse_oauth_consent_event(event, self.model) + if update is None: + update = super()._parse_chunk_from_openai( + event, + options, + function_call_ids, + seen_reasoning_delta_item_ids, + ) if _uses_foundry_agent_session(options.get("conversation_id")): - parsed_chunk.conversation_id = None - return parsed_chunk + update.conversation_id = None + return update @override def _check_model_presence(self, options: dict[str, Any]) -> None: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 57522fb886..fc2b29e1e4 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from agent_framework import ( ChatMiddlewareLayer, + ChatResponseUpdate, Content, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -33,6 +34,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage] if sys.version_info >= (3, 13): @@ -241,6 +244,20 @@ class RawFoundryChatClient( # type: ignore[misc] response_tools = super()._prepare_tools_for_openai(tools) return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools] + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, + ) -> ChatResponseUpdate: + """Parse streaming event, intercepting oauth_consent_request items.""" + update = try_parse_oauth_consent_event(event, self.model) + if update is not None: + return update + return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py new file mode 100644 index 0000000000..873d42c3d9 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -0,0 +1,75 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from agent_framework import ChatResponseUpdate, Content + +logger = logging.getLogger(__name__) + + +def _validate_consent_link(consent_link: str, item_id: str) -> str: + """Validate a consent link is HTTPS with a valid netloc. + + Returns the link unchanged if valid, or an empty string if not. + """ + parsed = urlparse(consent_link) + if parsed.scheme.lower() != "https" or not parsed.netloc: + logger.warning( + "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", + item_id, + ) + return "" + return consent_link + + +def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None: + """Parse an oauth_consent_request from a streaming event, if present. + + Returns a ``ChatResponseUpdate`` when *event* is a + ``response.output_item.added`` carrying an ``oauth_consent_request`` item + or a top-level ``response.oauth_consent_requested`` event, + or ``None`` so the caller can fall through to the base implementation. + """ + consent_link: str = "" + raw_item: Any = None + + event_type = getattr(event, "type", None) + + if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + raw_item = event.item + consent_link = getattr(raw_item, "consent_link", None) or "" + elif event_type == "response.oauth_consent_requested": + raw_item = event + consent_link = getattr(event, "consent_link", None) or "" + else: + return None + + item_id = getattr(raw_item, "id", "") + + if consent_link: + consent_link = _validate_consent_link(consent_link, item_id) + + contents: list[Content] = [] + if consent_link: + contents.append( + Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=raw_item, + ) + ) + else: + logger.warning( + "Received oauth_consent_request output without valid consent_link (item id=%s)", + item_id, + ) + + return ChatResponseUpdate( + contents=contents, + role="assistant", + model=model, + raw_representation=event, + ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 73670d0bbc..e110e540fe 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -10,7 +10,17 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool +from agent_framework import ( + AgentResponse, + AgentSession, + ChatContext, + ChatMiddleware, + ChatResponse, + ChatResponseUpdate, + Message, + tool, +) +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -287,6 +297,31 @@ def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id assert result.conversation_id is None +def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None: + """Test that agent-session stream updates do not overwrite session.service_session_id.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + parsed = ChatResponseUpdate(conversation_id="resp_123") + with patch( + "agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai", + return_value=parsed, + ): + result = client._parse_chunk_from_openai( + event=MagicMock(type="response.output_text.delta"), + options={"conversation_id": "agent-session-123"}, + function_call_ids={}, + ) + + assert result.conversation_id is None + + def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None: """Test that _check_model_presence does nothing (model is on service).""" @@ -622,3 +657,158 @@ async def test_foundry_agent_custom_client_run() -> None: assert isinstance(response, AgentResponse) assert response.text is not None assert "response test" in response.text.lower() + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a7b57029ba..2ef5ca04ee 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo from agent_framework._telemetry import get_user_agent from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.ai.projects.models import MCPTool as FoundryMCPTool from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential @@ -993,3 +994,165 @@ def test_get_mcp_tool_with_connection_id() -> None: description="GitHub MCP via Foundry", ) assert tool_obj is not None + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event + assert update.model == "test-model" + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py new file mode 100644 index 0000000000..2ab209e141 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event + +# region _validate_consent_link tests + + +def test_validate_consent_link_accepts_valid_https() -> None: + """A valid HTTPS URL with a netloc passes validation.""" + link = "https://consent.example.com/auth?code=123" + assert _validate_consent_link(link, "item-1") == link + + +def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None: + """An HTTP link is rejected and a warning is logged.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("http://insecure.example.com/login", "item-2") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-2" in caplog.text + + +def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with an empty netloc (e.g. https:///path) is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("https:///path", "item-3") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-3" in caplog.text + + +def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None: + """A non-URL string is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("not-a-url", "item-4") + assert result == "" + + +# endregion + +# region try_parse_oauth_consent_event tests + + +def _make_output_item_event( + *, + item_type: str = "oauth_consent_request", + consent_link: Any = "https://consent.example.com/auth", + item_id: str = "oauth-item-1", +) -> MagicMock: + """Create a mock ``response.output_item.added`` event.""" + event = MagicMock() + event.type = "response.output_item.added" + item = MagicMock() + item.type = item_type + item.consent_link = consent_link + item.id = item_id + event.item = item + return event + + +def _make_top_level_event( + *, + consent_link: Any = "https://consent.example.com/authorize", + event_id: str = "consent-event-1", +) -> MagicMock: + """Create a mock ``response.oauth_consent_requested`` event.""" + event = MagicMock() + event.type = "response.oauth_consent_requested" + event.consent_link = consent_link + event.id = event_id + return event + + +def test_returns_none_for_unrelated_event() -> None: + """An event with a non-oauth type returns None.""" + event = MagicMock() + event.type = "response.output_text.delta" + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_returns_none_for_event_without_type() -> None: + """An event object missing a 'type' attribute returns None.""" + event = object() # no type attribute + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_parses_output_item_added_with_valid_link() -> None: + """A response.output_item.added event with a valid HTTPS link produces Content.""" + event = _make_output_item_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert update.role == "assistant" + assert update.model == "test-model" + assert update.raw_representation is event + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/auth" + + +def test_parses_top_level_consent_requested_event() -> None: + """A response.oauth_consent_requested event produces Content.""" + event = _make_top_level_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/authorize" + + +def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None: + """A non-HTTPS consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """A None consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link=None, item_id="item-none") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """An empty-string consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="", item_id="item-empty") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with empty netloc (https:///path) is rejected.""" + event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +# endregion From 0b69d7fd151e2a176ad182648cb419a56584b8f8 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 24 Apr 2026 19:54:59 +0900 Subject: [PATCH 52/52] Python: Bump Python package versions for 1.2.0 release (#5468) * Bump Python package versions for 1.2.0 release Released tier bumps 1.1.1 -> 1.2.0 (core, openai, foundry, root) to reflect additive public APIs landed since 1.1.0: functional workflow API (#4238) and FunctionTool SKIP_PARSING sentinel (#5424). All beta packages stamped 1.0.0b260424, alpha packages 1.0.0a260424. All 26 non-core agent-framework-core floors raised to >=1.2.0,<2. CHANGELOG consolidates the never-tagged 1.1.1 entries with the post-merge additions into [1.2.0]. * Update CHANGELOG footer links for 1.2.0 Advance [Unreleased] comparison base from python-1.1.0 to python-1.2.0 and add a [1.2.0] reference link comparing python-1.1.0...python-1.2.0 so the heading links resolve correctly. * Fix CHANGELOG: restore [1.1.1] section and add proper [1.2.0] Previous commit incorrectly renamed the [1.1.1] header to [1.2.0], which wiped the historical 1.1.1 entries and wrongly attributed them to 1.2.0. This restores [1.1.1] to its origin/main content and adds a new [1.2.0] section above containing only the commits in python-1.1.1..HEAD: - #4238 functional workflow API - #5142 GitHub Copilot OpenTelemetry - #2403 A2A bridge support - #5070 oauth_consent_request events in Foundry clients - #5447 FoundryAgent hosted agent sessions - #5459 hosting server dependency upgrade + types - #5389 AG-UI reasoning/multimodal parsing fix - #5440 stop [TOOLBOXES] warning spam - #5455 user agent prefix fix Also corrects the [1.2.0] compare base to python-1.1.1 (not 1.1.0) and adds the missing [1.1.1] reference link. --- python/CHANGELOG.md | 24 +++++++-- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry/pyproject.toml | 4 +- .../packages/foundry_hosting/pyproject.toml | 2 +- python/packages/foundry_local/pyproject.toml | 4 +- python/packages/gemini/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- .../tests/test_github_copilot_agent.py | 4 +- python/packages/hyperlight/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/openai/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 54 +++++++++---------- 31 files changed, 103 insertions(+), 87 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 64439fcbbf..c3291f7604 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-04-24 + +### Added +- **agent-framework-core**: Add functional workflow API ([#4238](https://github.com/microsoft/agent-framework/pull/4238)) +- **agent-framework-core**, **agent-framework-github-copilot**: Add OpenTelemetry integration for `GitHubCopilotAgent` ([#5142](https://github.com/microsoft/agent-framework/pull/5142)) +- **agent-framework-a2a**: Add Agent Framework to A2A bridge support ([#2403](https://github.com/microsoft/agent-framework/pull/2403)) +- **agent-framework-foundry**: Surface `oauth_consent_request` events from Responses API in Foundry clients ([#5070](https://github.com/microsoft/agent-framework/pull/5070)) + +### Changed +- **agent-framework-core**, **agent-framework-foundry**: Update `FoundryAgent` for hosted agent sessions ([#5447](https://github.com/microsoft/agent-framework/pull/5447)) +- **agent-framework-foundry-hosting**: Upgrade hosting server dependency and add more type support ([#5459](https://github.com/microsoft/agent-framework/pull/5459)) + +### Fixed +- **agent-framework-ag-ui**: Fix reasoning role and multimodal media parsing to follow specification ([#5389](https://github.com/microsoft/agent-framework/pull/5389)) +- **agent-framework-foundry**: Stop emitting `[TOOLBOXES]` warning for every `FoundryChatClient` call ([#5440](https://github.com/microsoft/agent-framework/pull/5440)) +- **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-cosmos**: Fix user agent prefix ([#5455](https://github.com/microsoft/agent-framework/pull/5455)) + ## [1.1.1] - 2026-04-23 ### Added @@ -26,8 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125)) - **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414)) - **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384)) -- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads - ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) +- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads ([#5424](https://github.com/microsoft/agent-framework/pull/5424)) ## [1.1.0] - 2026-04-21 @@ -961,7 +977,9 @@ 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.1.0...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...HEAD +[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0 +[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1 [1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0 [1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1 [1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index cfe1c33da4..83d8ecf42f 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 5c79328794..5b6e40c95e 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "ag-ui-protocol>=0.1.16,<0.2", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index c82ce7f040..9292ba7fc2 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index d50cf2048a..5f605943b6 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 76a0c50ae4..ddbe5bf06b 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index eb2239563e..49f557a835 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index acea0c2749..c20c248708 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index 0662ab25c1..9a936ea8b6 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index 6a9069432d..35f9a7f3ff 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 396f3b32e3..d2d1712f94 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index d406965cdd..3dedc1ecba 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -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.1.1" +version = "1.2.0" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index 6050f080ec..155b7e3edc 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index fa308dbf55..31fdf39234 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai>=1.99.0,<3", "opentelemetry-sdk>=1.39.0,<2", "fastapi>=0.115.0,<0.133.1", diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index b55955d09d..7d73511188 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry/pyproject.toml b/python/packages/foundry/pyproject.toml index 58e50d9ed3..68d938241e 100644 --- a/python/packages/foundry/pyproject.toml +++ b/python/packages/foundry/pyproject.toml @@ -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.1.1" +version = "1.2.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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-openai>=1.1.0,<2", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "azure-ai-projects>=2.1.0,<3.0", diff --git a/python/packages/foundry_hosting/pyproject.toml b/python/packages/foundry_hosting/pyproject.toml index a9d0393a1d..9746cc6605 100644 --- a/python/packages/foundry_hosting/pyproject.toml +++ b/python/packages/foundry_hosting/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-ai-agentserver-core==2.0.0b3", "azure-ai-agentserver-responses==1.0.0b5", "azure-ai-agentserver-invocations==1.0.0b3", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index 5dbdcfb15c..d7c0416ffb 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "agent-framework-openai>=1.1.0,<2", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/gemini/pyproject.toml b/python/packages/gemini/pyproject.toml index 2a2d03fe0e..384163fbee 100644 --- a/python/packages/gemini/pyproject.toml +++ b/python/packages/gemini/pyproject.toml @@ -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.0a260423" +version = "1.0.0a260424" 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.1.1,<2.0", + "agent-framework-core>=1.2.0,<2.0", "google-genai>=1.0.0,<2.0.0", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 78364f08a0..eca6a3ae43 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'", ] diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index 4d953d9c36..4820ceed22 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -207,9 +207,7 @@ class TestGitHubCopilotAgentInit: def test_default_options_returns_independent_copy(self) -> None: """Test that mutating the returned dict does not affect internal state.""" - agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( - default_options={"model": "gpt-5.1-mini"} - ) + agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(default_options={"model": "gpt-5.1-mini"}) opts = agent.default_options opts["model"] = "mutated" assert agent._settings.get("model") == "gpt-5.1-mini" diff --git a/python/packages/hyperlight/pyproject.toml b/python/packages/hyperlight/pyproject.toml index 1b5e650935..f9f5abd41f 100644 --- a/python/packages/hyperlight/pyproject.toml +++ b/python/packages/hyperlight/pyproject.toml @@ -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.0a260423" +version = "1.0.0a260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "hyperlight-sandbox>=0.3.0,<0.4", "hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((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.3.0,<0.4", diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index ed4329ff80..ca5f4837ad 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 52dc028ce0..87042bdffb 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 0b0368ba89..3cd643e7ef 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/openai/pyproject.toml b/python/packages/openai/pyproject.toml index f53258eb98..26227d2cf2 100644 --- a/python/packages/openai/pyproject.toml +++ b/python/packages/openai/pyproject.toml @@ -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.1.1" +version = "1.2.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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "openai>=1.99.0,<3", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index 58ea3ce9c5..c946440b80 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index baa72d89a3..a828479709 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index b9a0c50498..662cdfe13c 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -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.0b260423" +version = "1.0.0b260424" 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.1.1,<2", + "agent-framework-core>=1.2.0,<2", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index 6b482161c0..67853a9ec1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -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.1.1" +version = "1.2.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[all]==1.1.1", + "agent-framework-core[all]==1.2.0", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index 6f1b6b0bbe..bb422b4478 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -96,7 +96,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.1.1" +version = "1.2.0" source = { virtual = "." } dependencies = [ { name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -151,7 +151,7 @@ dev = [ [[package]] name = "agent-framework-a2a" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/a2a" } dependencies = [ { name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -166,7 +166,7 @@ requires-dist = [ [[package]] name = "agent-framework-ag-ui" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/ag-ui" } dependencies = [ { name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -194,7 +194,7 @@ provides-extras = ["dev"] [[package]] name = "agent-framework-anthropic" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/anthropic" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -209,7 +209,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-ai-search" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azure-ai-search" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -224,7 +224,7 @@ requires-dist = [ [[package]] name = "agent-framework-azure-cosmos" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azure-cosmos" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -239,7 +239,7 @@ requires-dist = [ [[package]] name = "agent-framework-azurefunctions" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/azurefunctions" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -261,7 +261,7 @@ dev = [] [[package]] name = "agent-framework-bedrock" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/bedrock" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -278,7 +278,7 @@ requires-dist = [ [[package]] name = "agent-framework-chatkit" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/chatkit" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -293,7 +293,7 @@ requires-dist = [ [[package]] name = "agent-framework-claude" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/claude" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -308,7 +308,7 @@ requires-dist = [ [[package]] name = "agent-framework-copilotstudio" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/copilotstudio" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -323,7 +323,7 @@ requires-dist = [ [[package]] name = "agent-framework-core" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/core" } dependencies = [ { name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -395,7 +395,7 @@ provides-extras = ["all"] [[package]] name = "agent-framework-declarative" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/declarative" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -420,7 +420,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }] [[package]] name = "agent-framework-devui" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/devui" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -458,7 +458,7 @@ provides-extras = ["dev", "all"] [[package]] name = "agent-framework-durabletask" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/durabletask" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -485,7 +485,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }] [[package]] name = "agent-framework-foundry" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/foundry" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -523,7 +523,7 @@ requires-dist = [ [[package]] name = "agent-framework-foundry-local" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/foundry_local" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -540,7 +540,7 @@ requires-dist = [ [[package]] name = "agent-framework-gemini" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/gemini" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -555,7 +555,7 @@ requires-dist = [ [[package]] name = "agent-framework-github-copilot" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/github_copilot" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -570,7 +570,7 @@ requires-dist = [ [[package]] name = "agent-framework-hyperlight" -version = "1.0.0a260423" +version = "1.0.0a260424" source = { editable = "packages/hyperlight" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -589,7 +589,7 @@ requires-dist = [ [[package]] name = "agent-framework-lab" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/lab" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -670,7 +670,7 @@ dev = [ [[package]] name = "agent-framework-mem0" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/mem0" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -685,7 +685,7 @@ requires-dist = [ [[package]] name = "agent-framework-ollama" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/ollama" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -700,7 +700,7 @@ requires-dist = [ [[package]] name = "agent-framework-openai" -version = "1.1.1" +version = "1.2.0" source = { editable = "packages/openai" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -715,7 +715,7 @@ requires-dist = [ [[package]] name = "agent-framework-orchestrations" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/orchestrations" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -726,7 +726,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }] [[package]] name = "agent-framework-purview" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/purview" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -743,7 +743,7 @@ requires-dist = [ [[package]] name = "agent-framework-redis" -version = "1.0.0b260423" +version = "1.0.0b260424" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },