Compare commits

...
Author SHA1 Message Date
Tao Chen 2aa792fc36 Fix new types 2026-04-15 14:01:04 -07:00
Tao Chen 218ad88f19 Upgrade agentserver packages 2026-04-15 13:38:17 -07:00
Tao ChenandGitHub 9e3983e547 Move samples (#5281) 2026-04-15 11:33:15 -07:00
Tao ChenandGitHub 383a2afca2 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
2026-04-15 10:46:19 -07:00
Tao Chen 0402b1aac4 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-14 10:32:14 -07:00
485af07b8c Python: Add GeminiChatClient (#4847)
* Add agent-framework-gemini package

* Add AGENTS.md documentation

* Add LICENSE file

* Add README.md for agent-framework-gemini package

* Add Google Gemini API keys to .env.example

* Add Google Gemini chat client implementation

* Add tests for GeminiChatClient

* Add Google Gemini agent examples

* Fix client inheritence order

* Update Gemini agent examples

* Update documentation

* Update AGENTS.md

* Add tests for JSON string handling in GeminiChatClient

* Add final response assembly test in GeminiChatClient

* Add tests for handling empty candidates in GeminiChatClient

* Improve Pydantic response handling in GeminiChatClient

* Add tests for function result resolution and callable tool normalization

* Add test for function result resolution when call_id is generated

* Refactor GeminiChatClient to correct inheritance order

Also updates constructor parameter order for environment file handling

* Enhance documentation and clarify Gemini-specific fields

* Update ThinkingConfig with new attributes and type

* Add tests for GoogleSearch and GoogleMaps configs

* Suppress valid-type mypy error on GeminiChatOptionsT

* Move service_url method near overrides

* Order _prepare_config kwargs by base then Gemini-specific

* Use FunctionCallingConfigMode for clarity and type safety

* Fix code_execution doc

* Add agent-framework-gemini to project dependencies

* Remove package from core dependencies

Initial release will be done without agent-framework-gemini in
core[all].

* Move integration tests into one file

* Remove __init__.py file from gemini tests directory

* Introduce RawGeminiChatClient as lightweight chat client

Updated GeminiChatClient to inherit from RawGeminiChatClient, maintaining full functionality with added features.

* Updated variable names from `model_id` to `model`

Across the codebase, including environment variables and client initialization. Adjusted related tests and sample scripts to reflect this change, ensuring consistency in the usage of the Gemini model identifier.

* Update AGENTS.md

* Update Gemini package to alpha status

* Fix docstrings in Gemini tests

* Change 'model_id' to 'model' in response handling

* Fix model property change in response handling

* Add built-in tool factory methods to Gemini client

Replaces boolean tool options (code_execution, google_search_grounding,
google_maps_grounding) with static factory methods that return types.Tool
objects: get_code_interpreter_tool, get_web_search_tool, get_mcp_tool,
get_file_search_tool, and get_maps_grounding_tool.

Simplifies _prepare_tools to a single translation boundary between
FunctionTool (framework) and FunctionDeclaration (Gemini API), with
types.Tool objects passed through unchanged.

* Surface code execution parts

_parse_parts now maps executable_code and code_execution_result
parts to text Content objects so callers can see the code run
and its output. Unknown part types log at debug level rather than
being silently dropped.

* Update Gemini client documentation

* Unify Gemini model name

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

* Update Agent Framework core version

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

* Add Python 3.14 in classifiers

* Replace kwargs with parameters in tool factories

* Refactor chat options handling in Gemini client

* Add tests for handling unknown and consumed keys

* Update Gemini documentation

Now reflects new options and built-in tool factory methods

* Change build system to flit

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

* Fix build system in pyproject.toml

* Fix type checking for generate_content_stream

---------

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-04-14 10:18:26 +00:00
Dineshsuriya DandGitHub 64c68ca857 Python: Skip get_final_response in OTel _finalize_stream when stream errored (#5232)
* Python: Skip get_final_response in OTel _finalize_stream when stream errored

When a streaming error occurs, _finalize_stream (a cleanup hook registered by
AgentTelemetryLayer) was unconditionally calling get_final_response(), which
triggers all registered result hooks including after_run context providers.
This caused providers to fire incorrectly on error paths.

Guard against this by checking result_stream._consumed: True only after
StopAsyncIteration (normal completion), False when an exception was raised.
The fix applies to both the chat client and agent telemetry layers.

Closes #5231

* Python: Expose consumed/stream_error on ResponseStream and capture error in OTel span

Address Copilot review feedback on #5232:

- Add `_stream_error: Exception | None` to ResponseStream, set in __anext__'s
  except branch so cleanup hooks can inspect the failure.
- Expose public `consumed` and `stream_error` properties to avoid coupling
  observability.py to private stream internals.
- Update both _finalize_stream closures (chat and agent layers) to use the
  public properties and call capture_exception() with the stream error before
  returning early, ensuring the OTel span records the failure rather than
  closing silently.

* Python: Address Copilot review feedback on stream error handling

- Use stream_error is not None as the guard in _finalize_stream instead of
  not consumed, so the early-return path is keyed precisely to actual errors
  rather than any non-normal completion state.
- Clear _stream_error after _run_cleanup_hooks() completes to avoid retaining
  the exception traceback (and any large object graphs it references) on the
  stream instance beyond the cleanup phase.

* Python: Remove consumed/stream_error properties, use private attrs directly

Per review feedback: since observability.py and _types.py are in the same
package, accessing _stream_error directly is fine and the public properties
are unnecessary.

* Python: Fix Pyright reportPrivateUsage via inline ignore comments

Keep _stream_error private (consistent with rest of ResponseStream), and
suppress reportPrivateUsage at the call sites in observability.py with
inline pyright: ignore comments — access is intentional within the package.
2026-04-14 09:30:31 +00:00
Eduard van ValkenburgandGitHub 98e17764a4 Python: Fix DevUI streaming memory growth and add cross-platform regression coverage (#5221)
* fix for memory leak in devui

* update async sleep

* remove old func
2026-04-14 09:27:52 +00:00
Tao ChenandGitHub 7bb0feca59 Python: Move InMemory history provider injection to the first invocation (#5236)
* Move InMemory history provider injection to the first invocation

* Add tests
2026-04-14 07:13:42 +00:00
f183f888a3 Python: AG-UI deterministic state updates from tool results (#5201)
* AG-UI deterministic state updates from tool results

* fix(ag-ui): address PR #5201 review comments

1. Add missing AGUIEventConverter, AGUIHttpService, __version__ to
   _IMPORTS in core ag_ui lazy-export list to match the .pyi stub.

2. Coalesce predictive and deterministic state snapshots into a single
   StateSnapshotEvent when both mechanisms are active on the same tool
   result, reducing redundant snapshot traffic.

3. Update state_update() docstring to clarify that a predictive snapshot
   may be emitted before the deterministic one when predict_state_config
   is active.

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 04:58:09 +00:00
3c31ac28b5 Python: Fix HandoffBuilder dropping function-level middleware when cloning agents (#5220)
* Fix HandoffBuilder dropping function-level middleware when cloning agents (#5173)

_clone_chat_agent() was using agent.agent_middleware (agent-level only)
instead of agent.middleware (all types), which silently dropped any
function middleware registered on the original agent.

Changed to use agent.middleware to preserve all middleware types
(agent, function, and chat) during cloning.

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

* Python: Fix HandoffBuilder dropping function-level middleware when cloning agents

Fixes #5173

* Fix false-positive middleware regression test (#5173)

The test used isinstance(m, FunctionMiddleware) which matched
_AutoHandoffMiddleware (always appended during build) instead of the
user's @function_middleware decorator. Assert directly that
tracking_middleware is present in the cloned agent's middleware list.

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

* Address review feedback for #5173: Python: [Bug]: HandoffBuilder drops function-level middleware when cloning agents

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 04:52:03 +00:00
1b95e8585d Python: Add allowed_checkpoint_types support to CosmosCheckpointStorage for parity with FileCheckpointStorage (#5202)
* Python: Add allowed_checkpoint_types support to CosmosCheckpointStorage (#5200)

Add allowed_checkpoint_types parameter to CosmosCheckpointStorage for
parity with FileCheckpointStorage. This ensures both providers use the
same restricted pickle deserialization by default.

Changes:
- Accept allowed_checkpoint_types kwarg in __init__, stored as frozenset
- Convert _document_to_checkpoint from @staticmethod to instance method
- Forward allowed_types to decode_checkpoint_value on all load paths
- Update class docstring to describe the new parameter
- Add tests covering built-in safe types, app type opt-in/blocking,
  and all load paths (load, list_checkpoints, get_latest)
- Add changelog entry noting the breaking behavior change

BREAKING CHANGE: CosmosCheckpointStorage now uses restricted pickle
deserialization by default. Checkpoints containing application-defined
types will require passing those types via allowed_checkpoint_types.

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

* Python: Add `allowed_checkpoint_types` support to `CosmosCheckpointStorage` for parity with `FileCheckpointStorage`

Fixes #5200

* Address PR review: add pickle security warning and fix docstring examples

- Reintroduce explicit security warning about pickle deserialization risks
- Convert Example:: block to .. code-block:: python with imports for
  consistency with other docstring examples
- Note: PR title should be updated to include [BREAKING] prefix per
  changelog convention (comment #3, requires GitHub UI change)

Fixes #5200

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 02:20:55 +00:00
Tao Chen 448f46aff2 Merge branch 'main' into feature/python-foundry-hosted-agent-vnext 2026-04-13 16:47:46 -07:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
b89adb280b Python: skill name validation improvements (#4530)
* Initial plan

* Port .NET validation improvements to Python skills: reject consecutive hyphens and enforce directory name match

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* Fix E501 lint error: split long error message string in _validate_skill_metadata

Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-04-13 23:39:09 +00:00
Tao ChenandGitHub 9ce2aafff7 Add tests and more content types (#5235)
* Add tests

* fix tests and sample

* Fix formatting

* Remove function approval contents
2026-04-13 16:12:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
913397492f Bump pygments from 2.19.2 to 2.20.0 in /python (#4978)
Bumps [pygments](https://github.com/pygments/pygments) from 2.19.2 to 2.20.0.
- [Release notes](https://github.com/pygments/pygments/releases)
- [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES)
- [Commits](https://github.com/pygments/pygments/compare/2.19.2...2.20.0)

---
updated-dependencies:
- dependency-name: pygments
  dependency-version: 2.20.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 22:52:34 +00:00
952e685e17 Python: Fix python-feature-lifecycle skill YAML frontmatter (#5226)
* Fix python-feature-lifecycle skill YAML frontmatter

Remove copyright comment that preceded the YAML frontmatter delimiter,
which prevented the skill from loading. The --- block must be the very
first line of SKILL.md.

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

* fix: update broken eslint-react plugin links in devui README

The upstream eslint-react repo moved plugins from packages/plugins/
to the top-level plugins/ directory, causing 404 errors detected by
linkspector CI.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 22:28:06 +00:00
b1fb63eb81 .NET: Update AGUI service to support session storage (#5193)
* Update AGUI service to support session storage

* Apply suggestion from @Copilot

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

* Address PR comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-13 18:03:51 +00:00
Jacob AlberandGitHub 76fe7319e0 .NET: feat: Refactor Handoff Orchestration and add HITL support (#5174)
* feat: Refactor Handoff Orchestration and add HITL support

* Change HandoffAgentExecutor to use factory-based instantiation
* Extract shared request collection logic in AIAgentUnservicedRequestsCollector
* Refactor HandoffAgentExecutor to use the "ContinueTurn" pattern as in AIAgentHostExecutor

* fix: Remove '$' from exception strings
2026-04-13 14:59:17 +00:00
westeyandGitHub 39b560f83c Add missing path to verify-samples run checkout (#5194) 2026-04-13 11:00:31 +00:00
Tao ChenandGitHub a98a585afb Update dependency (#5215) 2026-04-10 16:10:35 -07:00
Tao ChenandGitHub 615ef9049f 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
2026-04-10 10:18:32 -07:00
3e864cdb4c .NET: Update version to 1.1.0 (#5204)
* Update version to 1.1.0

* Apply suggestion from @Copilot

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-10 15:28:00 +01:00
14d2ab3262 Standardize file skills terminology on 'directory' (#5205)
Rename authored identifiers, XML docs, log messages, and comments
from 'folder' to 'directory' across the file skills codebase for
consistency with the agentskills.io specification and .NET conventions.

Public API changes (experimental):
- ScriptFolders → ScriptDirectories
- ResourceFolders → ResourceDirectories

.NET BCL API calls (Directory.Exists, Path.GetDirectoryName, etc.)
were already using 'directory' and are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 15:27:45 +01:00
e5f7b9c260 .NET: Support reflection for discovery of resources and scripts in class-based skills (#5183)
* support reflection for discovery of resources and scripts in class-based skills

* fix format issues

* refactor samples to use reflection

* Validate resource member signatures during discovery

Add discovery-time validation in AgentClassSkill.DiscoverResources() to
fail fast when [AgentSkillResource] is applied to members with incompatible
signatures:

- Reject indexer properties (getter has parameters)
- Reject methods with parameters other than IServiceProvider or
  CancellationToken

Throws InvalidOperationException with actionable error messages instead of
allowing silent runtime failures when ReadAsync invokes the AIFunction with
no named arguments.

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

* prevent duplicates

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 11:56:28 +01:00
164 changed files with 10568 additions and 2682 deletions
+2 -1
View File
@@ -48,7 +48,8 @@ jobs:
.
.github
dotnet
workflow-samples
python
declarative-agents
- name: Setup dotnet
uses: actions/setup-dotnet@v5.2.0
+8 -8
View File
@@ -1,21 +1,21 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<RCNumber>6</RCNumber>
<VersionPrefix>1.1.0</VersionPrefix>
<RCNumber>1</RCNumber>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260402.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260402.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260410.1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.0.0</GitTag>
<GitTag>1.1.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
<!-- Package validation. Baseline Version should be the latest version available on NuGet. -->
<PackageValidationBaselineVersion>1.0.0-rc5</PackageValidationBaselineVersion>
<!-- Enable validation for RC packages and GA packages -->
<EnablePackageValidation Condition="'$(IsReleaseCandidate)' == 'true' OR '$(IsReleased)' == 'true'">true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.0.0</PackageValidationBaselineVersion>
<!-- Enable validation for GA packages -->
<EnablePackageValidation Condition="'$(IsReleased)' == 'true'">true</EnablePackageValidation>
<!-- Validate assembly attributes only for Publish builds -->
<NoWarn Condition="'$(Configuration)' != 'Publish'">$(NoWarn);CP0003</NoWarn>
<!-- Do not validate reference assemblies -->
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -1,8 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill.
// Class-based skills bundle all components into a single class implementation.
// This sample demonstrates how to define Agent Skills as C# classes using AgentClassSkill
// with attributes for automatic script and resource discovery.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -44,17 +45,16 @@ AgentResponse response = await agent.RunAsync(
Console.WriteLine($"Agent: {response.Text}");
/// <summary>
/// A unit-converter skill defined as a C# class.
/// A unit-converter skill defined as a C# class using attributes for discovery.
/// </summary>
/// <remarks>
/// Class-based skills bundle all components (name, description, body, resources, scripts)
/// into a single class.
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
/// are automatically discovered as skill scripts. Alternatively,
/// <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/> can be overridden.
/// </remarks>
internal sealed class UnitConverterSkill : AgentClassSkill
internal sealed class UnitConverterSkill : AgentClassSkill<UnitConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"unit-converter",
@@ -69,31 +69,40 @@ internal sealed class UnitConverterSkill : AgentClassSkill
3. Present the result clearly with both units.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource(
"conversion-table",
"""
# Conversion Tables
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> used to marshal parameters and return values
/// for scripts and resources.
/// </summary>
/// <remarks>
/// This override is not necessary for this sample, but can be used to provide custom
/// serialization options, for example a source-generated <c>JsonTypeInfoResolver</c>
/// for Native AOT compatibility.
/// </remarks>
protected override JsonSerializerOptions? SerializerOptions => null;
Formula: **result = value × factor**
/// <summary>
/// A conversion table resource providing multiplication factors.
/// </summary>
[AgentSkillResource("conversion-table")]
[Description("Lookup table of multiplication factors for common unit conversions.")]
public string ConversionTable => """
# Conversion Tables
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
"""),
];
Formula: **result = value × factor**
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert", ConvertUnits),
];
| From | To | Factor |
|-------------|-------------|----------|
| miles | kilometers | 1.60934 |
| kilometers | miles | 0.621371 |
| pounds | kilograms | 0.453592 |
| kilograms | pounds | 2.20462 |
""";
/// <summary>
/// Converts a value by the given factor.
/// </summary>
[AgentSkillScript("convert")]
[Description("Multiplies a value by a conversion factor and returns the result as JSON.")]
private static string ConvertUnits(double value, double factor)
{
double result = Math.Round(value * factor, 4);
@@ -1,12 +1,16 @@
# Class-Based Agent Skills Sample
This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`.
This sample demonstrates how to define **Agent Skills as C# classes** using `AgentClassSkill`
with **attributes** for automatic script and resource discovery.
## What it demonstrates
- Creating skills as classes that extend `AgentClassSkill`
- Bundling name, description, body, resources, and scripts into a single class
- Using `[AgentSkillResource]` on properties to define resources
- Using `[AgentSkillScript]` on methods to define scripts
- Automatic discovery (no need to override `Resources`/`Scripts`)
- Using the `AgentSkillsProvider` constructor with class-based skills
- Overriding `SerializerOptions` for Native AOT compatibility
## Skills Included
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001</NoWarn>
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -8,11 +8,12 @@
// Three different skill sources are registered here:
// 1. File-based: unit-converter (miles↔km, pounds↔kg) from SKILL.md on disk
// 2. Code-defined: volume-converter (gallons↔liters) using AgentInlineSkill
// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill
// 3. Class-based: temperature-converter (°F↔°C↔K) using AgentClassSkill with attributes
//
// For simpler, single-source scenarios, see the earlier steps in this sample series
// (e.g., Step01 for file-based, Step02 for code-defined, Step03 for class-based).
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -89,13 +90,15 @@ AgentResponse response = await agent.RunAsync(
Console.WriteLine($"Agent: {response.Text}");
/// <summary>
/// A temperature-converter skill defined as a C# class.
/// A temperature-converter skill defined as a C# class using attributes for discovery.
/// </summary>
internal sealed class TemperatureConverterSkill : AgentClassSkill
/// <remarks>
/// Properties annotated with <see cref="AgentSkillResourceAttribute"/> are automatically
/// discovered as skill resources, and methods annotated with <see cref="AgentSkillScriptAttribute"/>
/// are automatically discovered as skill scripts.
/// </remarks>
internal sealed class TemperatureConverterSkill : AgentClassSkill<TemperatureConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"temperature-converter",
@@ -110,29 +113,27 @@ internal sealed class TemperatureConverterSkill : AgentClassSkill
3. Present the result clearly with both temperature scales.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource(
"temperature-conversion-formulas",
"""
# Temperature Conversion Formulas
/// <summary>
/// A reference table of temperature conversion formulas.
/// </summary>
[AgentSkillResource("temperature-conversion-formulas")]
[Description("Formulas for converting between Fahrenheit, Celsius, and Kelvin.")]
public string ConversionFormulas => """
# Temperature Conversion Formulas
| From | To | Formula |
|-------------|-------------|---------------------------|
| Fahrenheit | Celsius | °C = (°F 32) × 5/9 |
| Celsius | Fahrenheit | °F = (°C × 9/5) + 32 |
| Celsius | Kelvin | K = °C + 273.15 |
| Kelvin | Celsius | °C = K 273.15 |
"""),
];
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert-temperature", ConvertTemperature),
];
| From | To | Formula |
|-------------|-------------|---------------------------|
| Fahrenheit | Celsius | °C = (°F 32) × 5/9 |
| Celsius | Fahrenheit | °F = (°C × 9/5) + 32 |
| Celsius | Kelvin | K = °C + 273.15 |
| Kelvin | Celsius | °C = K 273.15 |
""";
/// <summary>
/// Converts a temperature value between scales.
/// </summary>
[AgentSkillScript("convert-temperature")]
[Description("Converts a temperature value from one scale to another.")]
private static string ConvertTemperature(double value, string from, string to)
{
double result = (from.ToUpperInvariant(), to.ToUpperInvariant()) switch
@@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<NoWarn>$(NoWarn);MAAI001;CA1812</NoWarn>
<NoWarn>$(NoWarn);MAAI001;CA1812;IDE0051</NoWarn>
</PropertyGroup>
<ItemGroup>
@@ -13,6 +13,7 @@
// showing that DI works identically regardless of how the skill is defined.
// When prompted with a question spanning both domains, the agent uses both skills.
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -62,8 +63,8 @@ var distanceSkill = new AgentInlineSkill(
// Approach 2: Class-Based Skill with DI (AgentClassSkill)
// =====================================================================
// Handles weight conversions (pounds ↔ kilograms).
// Resources and scripts are encapsulated in a class. Factory methods
// CreateResource and CreateScript accept delegates with IServiceProvider.
// Resources and scripts are discovered via reflection using attributes.
// Methods with an IServiceProvider parameter receive DI automatically.
//
// Alternatively, class-based skills can accept dependencies through their
// constructor. Register the skill class itself in the ServiceCollection and
@@ -113,14 +114,13 @@ Console.WriteLine($"Agent: {response.Text}");
/// </summary>
/// <remarks>
/// This skill resolves <see cref="ConversionService"/> from the DI container
/// in both its resource and script functions. This enables clean separation of
/// concerns and testability while retaining the class-based skill pattern.
/// in both its resource and script methods. Methods with an <see cref="IServiceProvider"/>
/// parameter are automatically injected by the framework. Properties and methods annotated
/// with <see cref="AgentSkillResourceAttribute"/> and <see cref="AgentSkillScriptAttribute"/>
/// are automatically discovered via reflection.
/// </remarks>
internal sealed class WeightConverterSkill : AgentClassSkill
internal sealed class WeightConverterSkill : AgentClassSkill<WeightConverterSkill>
{
private IReadOnlyList<AgentSkillResource>? _resources;
private IReadOnlyList<AgentSkillScript>? _scripts;
/// <inheritdoc/>
public override AgentSkillFrontmatter Frontmatter { get; } = new(
"weight-converter",
@@ -135,25 +135,27 @@ internal sealed class WeightConverterSkill : AgentClassSkill
3. Present the result clearly with both units.
""";
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillResource>? Resources => this._resources ??=
[
CreateResource("weight-table", (IServiceProvider serviceProvider) =>
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.GetWeightTable();
}),
];
/// <summary>
/// Returns the weight conversion table from the DI-registered <see cref="ConversionService"/>.
/// </summary>
[AgentSkillResource("weight-table")]
[Description("Lookup table of multiplication factors for weight conversions.")]
private static string GetWeightTable(IServiceProvider serviceProvider)
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.GetWeightTable();
}
/// <inheritdoc/>
public override IReadOnlyList<AgentSkillScript>? Scripts => this._scripts ??=
[
CreateScript("convert", (double value, double factor, IServiceProvider serviceProvider) =>
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.Convert(value, factor);
}),
];
/// <summary>
/// Converts a value by the given factor using the DI-registered <see cref="ConversionService"/>.
/// </summary>
[AgentSkillScript("convert")]
[Description("Multiplies a value by a conversion factor and returns the result as JSON.")]
private static string Convert(double value, double factor, IServiceProvider serviceProvider)
{
var service = serviceProvider.GetRequiredService<ConversionService>();
return service.Convert(value, factor);
}
}
// ---------------------------------------------------------------------------
@@ -4,6 +4,7 @@ using System.ComponentModel;
using AGUIServer;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.Hosting;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
@@ -13,11 +14,11 @@ builder.Services.AddHttpClient().AddLogging();
builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.TypeInfoResolverChain.Add(AGUIServerSerializerContext.Default));
builder.Services.AddAGUI();
WebApplication app = builder.Build();
string endpoint = builder.Configuration["AZURE_OPENAI_ENDPOINT"] ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = builder.Configuration["AZURE_OPENAI_DEPLOYMENT_NAME"] ?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT_NAME is not set.");
const string AgentName = "AGUIAssistant";
// Create the AI agent with tools
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -27,7 +28,7 @@ var agent = new AzureOpenAIClient(
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsAIAgent(
name: "AGUIAssistant",
name: AgentName,
tools: [
AIFunctionFactory.Create(
() => DateTimeOffset.UtcNow,
@@ -48,7 +49,15 @@ var agent = new AzureOpenAIClient(
AGUIServerSerializerContext.Default.Options)
]);
// Register the agent with the host and configure it to use an in-memory session store
// so that conversation state is maintained across requests. In production, you may want to use a persistent session store.
builder
.AddAIAgent(AgentName, (_, _) => agent)
.WithInMemorySessionStore();
WebApplication app = builder.Build();
// Map the AG-UI agent endpoint
app.MapAGUI("/", agent);
app.MapAGUI(AgentName, "/");
await app.RunAsync();
@@ -1,284 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentRecord,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentReference,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.AsAIAgent(Azure.AI.Projects.AIProjectClient,Azure.AI.Projects.OpenAI.AgentVersion,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Azure.AI.Projects.AgentVersionCreationOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.CreateAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.String,System.String,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,Microsoft.Agents.AI.ChatClientAgentOptions,System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Azure.AI.Projects.AzureAIProjectChatClientExtensions.GetAIAgentAsync(Azure.AI.Projects.AIProjectClient,System.String,System.Collections.Generic.IList{Microsoft.Extensions.AI.AITool},System.Func{Microsoft.Extensions.AI.IChatClient,Microsoft.Extensions.AI.IChatClient},System.IServiceProvider,System.Threading.CancellationToken)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.AzureAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -1,9 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -21,6 +24,42 @@ namespace Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
/// </summary>
public static class AGUIEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps an AG-UI agent endpoint using an agent registered in dependency injection via <see cref="IHostedAgentBuilder"/>.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentBuilder">The hosted agent builder that identifies the agent registration.</param>
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
IHostedAgentBuilder agentBuilder,
[StringSyntax("route")] string pattern)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentBuilder);
return endpoints.MapAGUI(agentBuilder.Name, pattern);
}
/// <summary>
/// Maps an AG-UI agent endpoint using a named agent registered in dependency injection.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="agentName">The name of the keyed agent registration to resolve from dependency injection.</param>
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
string agentName,
[StringSyntax("route")] string pattern)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(agentName);
var agent = endpoints.ServiceProvider.GetRequiredKeyedService<AIAgent>(agentName);
return endpoints.MapAGUI(pattern, agent);
}
/// <summary>
/// Maps an AG-UI agent endpoint.
/// </summary>
@@ -28,11 +67,24 @@ public static class AGUIEndpointRouteBuilderExtensions
/// <param name="pattern">The URL pattern for the endpoint.</param>
/// <param name="aiAgent">The agent instance.</param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> for the mapped endpoint.</returns>
/// <remarks>
/// <para>
/// If an <see cref="AgentSessionStore"/> is registered in dependency injection keyed by the agent's name,
/// it will be used to persist conversation sessions across requests using the AG-UI thread ID as the
/// conversation identifier. If no session store is registered, sessions are ephemeral (not persisted).
/// </para>
/// </remarks>
public static IEndpointConventionBuilder MapAGUI(
this IEndpointRouteBuilder endpoints,
[StringSyntax("route")] string pattern,
AIAgent aiAgent)
{
ArgumentNullException.ThrowIfNull(endpoints);
ArgumentNullException.ThrowIfNull(aiAgent);
var agentSessionStore = endpoints.ServiceProvider.GetKeyedService<AgentSessionStore>(aiAgent.Name);
var hostAgent = new AIHostAgent(aiAgent, agentSessionStore ?? new NoopAgentSessionStore());
return endpoints.MapPost(pattern, async ([FromBody] RunAgentInput? input, HttpContext context, CancellationToken cancellationToken) =>
{
if (input is null)
@@ -63,21 +115,43 @@ public static class AGUIEndpointRouteBuilderExtensions
}
};
var threadId = string.IsNullOrWhiteSpace(input.ThreadId) ? Guid.NewGuid().ToString("N") : input.ThreadId;
var session = await hostAgent.GetOrCreateSessionAsync(threadId, cancellationToken).ConfigureAwait(false);
// Run the agent and convert to AG-UI events
var events = aiAgent.RunStreamingAsync(
var events = hostAgent.RunStreamingAsync(
messages,
session: session,
options: runOptions,
cancellationToken: cancellationToken)
.AsChatResponseUpdatesAsync()
.FilterServerToolsFromMixedToolInvocationsAsync(clientTools, cancellationToken)
.AsAGUIEventStreamAsync(
input.ThreadId,
threadId,
input.RunId,
jsonSerializerOptions,
cancellationToken);
// Wrap the event stream to save the session after streaming completes
var eventsWithSessionSave = SaveSessionAfterStreamingAsync(events, hostAgent, threadId, session, cancellationToken);
var sseLogger = context.RequestServices.GetRequiredService<ILogger<AGUIServerSentEventsResult>>();
return new AGUIServerSentEventsResult(events, sseLogger);
return new AGUIServerSentEventsResult(eventsWithSessionSave, sseLogger);
});
}
private static async IAsyncEnumerable<BaseEvent> SaveSessionAfterStreamingAsync(
IAsyncEnumerable<BaseEvent> events,
AIHostAgent hostAgent,
string threadId,
AgentSession session,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (BaseEvent evt in events.ConfigureAwait(false))
{
yield return evt;
}
await hostAgent.SaveSessionAsync(threadId, session, cancellationToken).ConfigureAwait(false);
}
}
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
</ItemGroup>
<ItemGroup>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0001</DiagnosticId>
<Target>T:OpenAI.Assistants.OpenAIAssistantClientExtensions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.OpenAI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.Workflows.Declarative.AzureAgentProvider.get_OpenAIClientOptions</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.Workflows.Declarative.Foundry.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -8,6 +8,10 @@ using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryFunc = System.Func<Microsoft.Agents.AI.Workflows.ExecutorConfig<Microsoft.Agents.AI.Workflows.ExecutorOptions>,
string,
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Specialized.HandoffAgentExecutor>>;
namespace Microsoft.Agents.AI.Workflows;
internal static class DiagnosticConstants
@@ -233,6 +237,57 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
return (TBuilder)this;
}
private Dictionary<string, ExecutorBinding> CreateExecutorBindings(WorkflowBuilder builder)
{
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// There are two types of ids being used in this method, and it is critical that we are clear about
// which one we are using, and where.
// AgentId...: comes from AIAgent.Id, is often an unreadable machine identifier (e.g. a Guid), and is used to address
// the handoffs
// ExecutorId: uses AIAgent.GetDescriptiveId() to use a friendlier name in telemetry, and is used for ExecutorBinding,
// which are subsequently used in building the workflow
// The outgoing dictionary maps from AgentId => ExecutorBinding
return this._allAgents.ToDictionary(keySelector: a => a.Id, elementSelector: CreateFactoryBinding);
ExecutorBinding CreateFactoryBinding(AIAgent agent)
{
if (!this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? handoffs))
{
handoffs = new();
}
// Use the ExecutorId as the placeholder id for a (possibly) future-bound factory
builder.AddSwitch(HandoffAgentExecutor.IdFor(agent), (SwitchBuilder sb) =>
{
foreach (HandoffTarget handoff in handoffs)
{
sb.AddCase<HandoffState>(state => state?.RequestedHandoffTargetAgentId == handoff.Target.Id, // Use AgentId for target matching
HandoffAgentExecutor.IdFor(handoff.Target)); // Use ExecutorId in for routing at the workflow level
}
sb.WithDefault(HandoffEndExecutor.ExecutorId);
});
ExecutorFactoryFunc factory =
(config, sessionId) => new(
new HandoffAgentExecutor(agent,
handoffs,
options));
// Make sure to use ExecutorId when binding the executor, not AgentId
ExecutorBinding binding = factory.BindExecutor(HandoffAgentExecutor.IdFor(agent));
builder.BindExecutor(binding);
return binding;
}
}
/// <summary>
/// Builds a <see cref="Workflow"/> composed of agents that operate via handoffs, with the next
/// agent to process messages selected by the current agent.
@@ -240,17 +295,12 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
/// <returns>The workflow built based on the handoffs in the builder.</returns>
public Workflow Build()
{
HandoffsStartExecutor start = new(this._returnToPrevious);
HandoffsEndExecutor end = new(this._returnToPrevious);
HandoffStartExecutor start = new(this._returnToPrevious);
HandoffEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
this._emitAgentResponseEvents,
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
// Create an AgentExecutor for each agent.
Dictionary<string, HandoffAgentExecutor> executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
// Create an factory-based ExecutorBinding for each agent.
Dictionary<string, ExecutorBinding> executors = this.CreateExecutorBindings(builder);
// Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
if (this._returnToPrevious)
@@ -263,7 +313,7 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
if (agent.Id != initialAgentId)
{
string agentId = agent.Id;
sb.AddCase<HandoffState>(state => state?.CurrentAgentId == agentId, executors[agentId]);
sb.AddCase<HandoffState>(state => state?.PreviousAgentId == agentId, executors[agentId]);
}
}
@@ -275,13 +325,6 @@ public class HandoffWorkflowBuilderCore<TBuilder> where TBuilder : HandoffWorkfl
builder.AddEdge(start, executors[this._initialAgent.Id]);
}
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
{
executors[agent.Id].Initialize(builder, end, executors,
this._targets.TryGetValue(agent, out HashSet<HandoffTarget>? targets) ? targets : []);
}
// Build the workflow.
return builder.WithOutputFrom(end).Build();
}
@@ -19,6 +19,9 @@ internal static class TurnExtensions
public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
=> turnTokenSetting ?? agentSetting ?? false;
public static bool ShouldEmitStreamingEvents(this HandoffState handoffState, bool? agentSetting)
=> handoffState.TurnToken.ShouldEmitStreamingEvents(agentSetting);
}
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
@@ -81,7 +84,11 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
// resumes can be processed in one invocation.
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
{
pendingMessages.Add(new ChatMessage(ChatRole.User, [response]));
pendingMessages.Add(new ChatMessage(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
@@ -104,7 +111,12 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
// resumes can be processed in one invocation.
return this.ProcessTurnMessagesAsync(async (pendingMessages, ctx, ct) =>
{
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result]));
pendingMessages.Add(new ChatMessage(ChatRole.Tool, [result])
{
AuthorName = this._agent.Name ?? this._agent.Id,
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
await this.ContinueTurnAsync(pendingMessages, ctx, this._currentTurnEmitEvents ?? false, ct).ConfigureAwait(false);
@@ -186,16 +198,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
cancellationToken);
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
#pragma warning disable MEAI001
Dictionary<string, ToolApprovalRequestContent> userInputRequests = new();
Dictionary<string, FunctionCallContent> functionCalls = new();
AgentResponse response;
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
if (emitEvents)
if (emitUpdateEvents)
{
#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.
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
@@ -206,7 +215,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
ExtractUnservicedRequests(update.Contents);
collector.ProcessAgentResponseUpdate(update);
updates.Add(update);
}
@@ -220,7 +229,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
cancellationToken: cancellationToken)
.ConfigureAwait(false);
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
collector.ProcessAgentResponse(response);
}
if (this._options.EmitAgentResponseEvents)
@@ -228,45 +237,8 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
{
Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask;
Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask;
await Task.WhenAll(userInputTask, functionCallTask)
.ConfigureAwait(false);
}
await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
return response;
void ExtractUnservicedRequests(IEnumerable<AIContent> contents)
{
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent userInputRequest)
{
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}
else if (content is ToolApprovalResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = userInputRequests.Remove(userInputResponse.RequestId);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
functionCalls.Add(functionCall.CallId, functionCall);
}
else if (content is FunctionResultContent functionResult)
{
_ = functionCalls.Remove(functionResult.CallId);
}
}
}
#pragma warning restore MEAI001
}
}
@@ -0,0 +1,78 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class AIAgentUnservicedRequestsCollector(AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>? userInputHandler,
AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? functionCallHandler)
{
private readonly Dictionary<string, ToolApprovalRequestContent> _userInputRequests = [];
private readonly Dictionary<string, FunctionCallContent> _functionCalls = [];
public Task SubmitAsync(IWorkflowContext context, CancellationToken cancellationToken)
{
Task userInputTask = userInputHandler != null && this._userInputRequests.Count > 0
? userInputHandler.ProcessRequestContentsAsync(this._userInputRequests, context, cancellationToken)
: Task.CompletedTask;
Task functionCallTask = functionCallHandler != null && this._functionCalls.Count > 0
? functionCallHandler.ProcessRequestContentsAsync(this._functionCalls, context, cancellationToken)
: Task.CompletedTask;
return Task.WhenAll(userInputTask, functionCallTask);
}
public void ProcessAgentResponseUpdate(AgentResponseUpdate update, Func<FunctionCallContent, bool>? functionCallFilter = null)
=> this.ProcessAIContents(update.Contents, functionCallFilter);
public void ProcessAgentResponse(AgentResponse response)
=> this.ProcessAIContents(response.Messages.SelectMany(message => message.Contents));
public void ProcessAIContents(IEnumerable<AIContent> contents, Func<FunctionCallContent, bool>? functionCallFilter = null)
{
foreach (AIContent content in contents)
{
if (content is ToolApprovalRequestContent userInputRequest)
{
if (this._userInputRequests.ContainsKey(userInputRequest.RequestId))
{
throw new InvalidOperationException($"ToolApprovalRequestContent with duplicate RequestId: {userInputRequest.RequestId}");
}
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
this._userInputRequests.Add(userInputRequest.RequestId, userInputRequest);
}
else if (content is ToolApprovalResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = this._userInputRequests.Remove(userInputResponse.RequestId);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
if (functionCallFilter == null || functionCallFilter(functionCall))
{
if (this._functionCalls.ContainsKey(functionCall.CallId))
{
throw new InvalidOperationException($"FunctionCallContent with duplicate CallId: {functionCall.CallId}");
}
this._functionCalls.Add(functionCall.CallId, functionCall);
}
}
else if (content is FunctionResultContent functionResult)
{
_ = this._functionCalls.Remove(functionResult.CallId);
}
}
}
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text.Json;
@@ -166,128 +165,331 @@ internal sealed class HandoffMessagesFilter
}
}
internal struct AgentInvocationResult(AgentResponse agentResponse, string? handoffTargetId)
{
public AgentResponse Response => agentResponse;
public string? HandoffTargetId => handoffTargetId;
[MemberNotNullWhen(true, nameof(HandoffTargetId))]
public bool IsHandoffRequested => this.HandoffTargetId != null;
}
internal record HandoffAgentHostState(HandoffState? CurrentTurnState, List<ChatMessage> FilteredIncomingMessages, List<ChatMessage> TurnMessages)
{
public HandoffState PrepareHandoff(AgentInvocationResult invocationResult, string currentAgentId)
{
if (this.CurrentTurnState == null)
{
throw new InvalidOperationException("Cannot create a handoff request: Out of turn.");
}
IEnumerable<ChatMessage> allMessages = [.. this.CurrentTurnState.Messages, .. this.TurnMessages, .. invocationResult.Response.Messages];
return new(this.CurrentTurnState.TurnToken, invocationResult.HandoffTargetId, allMessages.ToList(), currentAgentId);
}
}
/// <summary>Executor used to represent an agent in a handoffs workflow, responding to <see cref="HandoffState"/> events.</summary>
[Experimental(DiagnosticConstants.ExperimentalFeatureDiagnostic)]
internal sealed class HandoffAgentExecutor(
AIAgent agent,
HandoffAgentExecutorOptions options) : Executor<HandoffState, HandoffState>(agent.GetDescriptiveId(), declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffAgentExecutor :
StatefulExecutor<HandoffAgentHostState, HandoffState>
{
private static readonly JsonElement s_handoffSchema = AIFunctionFactory.Create(
([Description("The reason for the handoff")] string? reasonForHandoff) => { }).JsonSchema;
private readonly AIAgent _agent = agent;
public static string IdFor(AIAgent agent) => agent.GetDescriptiveId();
private readonly AIAgent _agent;
private readonly ChatClientAgentRunOptions? _agentOptions;
private readonly HandoffAgentExecutorOptions _options;
private readonly HashSet<string> _handoffFunctionNames = [];
private readonly Dictionary<string, string> _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
WorkflowBuilder builder,
Executor end,
Dictionary<string, HandoffAgentExecutor> executors,
HashSet<HandoffTarget> handoffs) =>
builder.AddSwitch(this, sb =>
{
if (handoffs.Count != 0)
{
Debug.Assert(this._agentOptions is null);
this._agentOptions = new()
{
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = options.HandoffInstructions,
Tools = [],
},
};
private static HandoffAgentHostState InitialStateFactory() => new(null, [], []);
int index = 0;
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
sb.AddCase<HandoffState>(state => state?.InvokedHandoff == handoffFunc.Name, executors[handoff.Target.Id]);
}
}
sb.WithDefault(end);
});
public override async ValueTask<HandoffState> HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
public HandoffAgentExecutor(AIAgent agent, HashSet<HandoffTarget> handoffs, HandoffAgentExecutorOptions options)
: base(IdFor(agent), InitialStateFactory)
{
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<ChatMessage> allMessages = message.Messages;
this._agent = agent;
this._options = options;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
this._agentOptions = CreateAgentHandoffContext(this._options.HandoffInstructions, handoffs, this._handoffFunctionNames, this._handoffFunctionToAgentId);
}
// 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(options.ToolCallFilteringBehavior);
IEnumerable<ChatMessage> messagesForAgent = message.InvokedHandoff is not null
? handoffMessagesFilter.FilterMessages(allMessages)
: allMessages;
private static ChatClientAgentRunOptions? CreateAgentHandoffContext(string? handoffInstructions, HashSet<HandoffTarget> handoffs, HashSet<string> functionNames, Dictionary<string, string> functionToAgentId)
{
ChatClientAgentRunOptions? result = null;
await foreach (var update in this._agent.RunStreamingAsync(messagesForAgent,
options: this._agentOptions,
cancellationToken: cancellationToken)
.ConfigureAwait(false))
if (handoffs.Count != 0)
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
foreach (var fcc in update.Contents.OfType<FunctionCallContent>()
.Where(fcc => this._handoffFunctionNames.Contains(fcc.Name)))
result = new()
{
requestedHandoff = fcc.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
ChatOptions = new()
{
AllowMultipleToolCalls = false,
Instructions = handoffInstructions,
Tools = [],
},
};
int index = 0;
foreach (HandoffTarget handoff in handoffs)
{
index++;
var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
functionNames.Add(handoffFunc.Name);
functionToAgentId[handoffFunc.Name] = handoff.Target.Id;
result.ChatOptions.Tools.Add(handoffFunc);
}
}
AgentResponse agentResponse = updates.ToAgentResponse();
return result;
}
if (options.EmitAgentResponseEvents)
private AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>? _userInputHandler;
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
return this.ConfigureUserInputHandling(base.ConfigureProtocol(protocolBuilder))
.SendsMessage<HandoffState>();
}
private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder)
{
this._userInputHandler = new AIContentExternalHandler<ToolApprovalRequestContent, ToolApprovalResponseContent>(
ref protocolBuilder,
portId: $"{this.Id}_UserInput",
intercepted: false,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref protocolBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: false, // TODO: Use this instead of manual function handling for handoff?
handler: this.HandleFunctionResultAsync);
return protocolBuilder;
}
private ValueTask HandleUserInputResponseAsync(
ToolApprovalResponseContent response,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId))
{
await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'.");
}
allMessages.AddRange(agentResponse.Messages);
// Merge the external response with any already-buffered regular messages so mixed-content
// resumes can be processed in one invocation.
return this.InvokeWithStateAsync((state, ctx, ct) =>
{
state.TurnMessages.Add(new ChatMessage(ChatRole.User, [response])
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
});
return this.ContinueTurnAsync(state, ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
FunctionResultContent result,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
{
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
// Merge the external response with any already-buffered regular messages so mixed-content
// 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"),
});
return this.ContinueTurnAsync(state, ctx, ct);
}, context, skipCache: false, cancellationToken);
}
private async ValueTask<HandoffAgentHostState?> ContinueTurnAsync(HandoffAgentHostState state, IWorkflowContext context, CancellationToken cancellationToken)
{
List<ChatMessage>? roleChanges = state.FilteredIncomingMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
bool emitUpdateEvents = state.CurrentTurnState!.ShouldEmitStreamingEvents(this._options.EmitAgentResponseUpdateEvents);
AgentInvocationResult result = await this.InvokeAgentAsync([.. state.FilteredIncomingMessages, .. state.TurnMessages], context, emitUpdateEvents, cancellationToken)
.ConfigureAwait(false);
if (this.HasOutstandingRequests && result.IsHandoffRequested)
{
throw new InvalidOperationException("Cannot request a handoff while holding pending requests.");
}
roleChanges.ResetUserToAssistantForChangedRoles();
string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
? targetAgentId
: this._agent.Id;
return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
// 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)
{
updates.Add(update);
if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
HandoffState outgoingState = state.PrepareHandoff(result, 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;
}
state.TurnMessages.AddRange(result.Response.Messages);
return state;
}
public override ValueTask HandleAsync(HandoffState message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(InvokeContinueTurnAsync, context, skipCache: false, cancellationToken);
ValueTask<HandoffAgentHostState?> 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)
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
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<ChatMessage> messagesForAgent = message.RequestedHandoffTargetAgentId is not null
? handoffMessagesFilter.FilterMessages(message.Messages)
: message.Messages;
// 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(), []);
return this.ContinueTurnAsync(state, context, cancellationToken);
}
}
public ValueTask ResetAsync() => default;
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(userInputRequestsTask, functionCallRequestsTask, baseTask).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;
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
}
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
private async ValueTask<AgentInvocationResult> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitUpdateEvents, CancellationToken cancellationToken = default)
{
AgentResponse response;
AIAgentUnservicedRequestsCollector collector = new(this._userInputHandler, this._functionCallHandler);
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
options: this._agentOptions,
cancellationToken: cancellationToken);
string? requestedHandoff = null;
List<AgentResponseUpdate> updates = [];
List<FunctionCallContent> candidateRequests = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false);
collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter);
bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest)
{
bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name);
if (isHandoffRequest)
{
candidateRequests.Add(candidateHandoffRequest);
}
return !isHandoffRequest;
}
}
if (candidateRequests.Count > 1)
{
string message = $"Duplicate handoff requests in single turn ([{string.Join(", ", candidateRequests.Select(request => request.Name))}]). Using last ({candidateRequests.Last().Name})";
await context.AddEventAsync(new WorkflowWarningEvent(message), cancellationToken).ConfigureAwait(false);
}
if (candidateRequests.Count > 0)
{
FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1];
requestedHandoff = handoffRequest.Name;
await AddUpdateAsync(
new AgentResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
},
cancellationToken
)
.ConfigureAwait(false);
}
response = updates.ToAgentResponse();
if (this._options.EmitAgentResponseEvents)
{
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
await collector.SubmitAsync(context, cancellationToken).ConfigureAwait(false);
return new(response, LookupHandoffTarget(requestedHandoff));
ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
return emitUpdateEvents ? context.YieldOutputAsync(update, cancellationToken) : default;
}
string? LookupHandoffTarget(string? requestedHandoff)
=> requestedHandoff != null
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>Executor used at the end of a handoff workflow to raise a final completed event.</summary>
internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffEndExecutor(bool returnToPrevious) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
@@ -21,9 +21,9 @@ internal sealed class HandoffsEndExecutor(bool returnToPrevious) : Executor(Exec
{
if (returnToPrevious)
{
await context.QueueStateUpdateAsync<string?>(HandoffConstants.CurrentAgentTrackerKey,
handoff.CurrentAgentId,
HandoffConstants.CurrentAgentTrackerScope,
await context.QueueStateUpdateAsync<string?>(HandoffConstants.PreviousAgentTrackerKey,
handoff.PreviousAgentId,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken)
.ConfigureAwait(false);
}
@@ -9,12 +9,12 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class HandoffConstants
{
internal const string CurrentAgentTrackerKey = "LastAgentId";
internal const string CurrentAgentTrackerScope = "HandoffOrchestration";
internal const string PreviousAgentTrackerKey = "LastAgentId";
internal const string PreviousAgentTrackerScope = "HandoffOrchestration";
}
/// <summary>Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.</summary>
internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
internal sealed class HandoffStartExecutor(bool returnToPrevious) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -32,15 +32,15 @@ internal sealed class HandoffsStartExecutor(bool returnToPrevious) : ChatProtoco
if (returnToPrevious)
{
return context.InvokeWithStateAsync(
async (string? currentAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
async (string? previousAgentId, IWorkflowContext context, CancellationToken cancellationToken) =>
{
HandoffState handoffState = new(new(emitEvents), null, messages, currentAgentId);
HandoffState handoffState = new(new(emitEvents), null, messages, previousAgentId);
await context.SendMessageAsync(handoffState, cancellationToken).ConfigureAwait(false);
return currentAgentId;
return previousAgentId;
},
HandoffConstants.CurrentAgentTrackerKey,
HandoffConstants.CurrentAgentTrackerScope,
HandoffConstants.PreviousAgentTrackerKey,
HandoffConstants.PreviousAgentTrackerScope,
cancellationToken);
}
@@ -7,6 +7,6 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
string? RequestedHandoffTargetAgentId,
List<ChatMessage> Messages,
string? CurrentAgentId = null);
string? PreviousAgentId = null);
@@ -113,6 +113,12 @@ public abstract class StatefulExecutor<TState> : Executor
{
if (!skipCache && !context.ConcurrentRunsEnabled)
{
if (this._stateCache is null)
{
this._stateCache = await context.ReadOrInitStateAsync(this.StateKey, this._initialStateFactory, this.Options.ScopeName, cancellationToken)
.ConfigureAwait(false);
}
TState newState = await invocation(this._stateCache ?? this._initialStateFactory(),
context,
cancellationToken).ConfigureAwait(false)
@@ -168,9 +174,12 @@ public abstract class StatefulExecutor<TState, TInput>(string id,
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput>(this.HandleAsync);
Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handlerDelegate = this.HandleAsync;
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? [])
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
@@ -203,19 +212,12 @@ public abstract class StatefulExecutor<TState, TInput, TOutput>(string id,
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
protocolBuilder.RouteBuilder.AddHandler<TInput, TOutput>(this.HandleAsync);
if (this.Options.AutoSendMessageHandlerResultObject)
{
protocolBuilder.SendsMessage<TOutput>();
}
if (this.Options.AutoYieldOutputHandlerResultObject)
{
protocolBuilder.YieldsOutput<TOutput>();
}
return protocolBuilder.SendsMessageTypes(sentMessageTypes ?? []).YieldsOutputTypes(outputTypes ?? []);
Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> handlerDelegate = this.HandleAsync;
return protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler(handlerDelegate))
.AddMethodAttributeTypes(handlerDelegate.Method)
.AddClassAttributeTypes(this.GetType())
.SendsMessageTypes(sentMessageTypes ?? [])
.YieldsOutputTypes(outputTypes ?? []);
}
/// <inheritdoc/>
@@ -43,27 +43,6 @@
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/net10.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net10.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -106,27 +85,6 @@
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/net472/Microsoft.Agents.AI.dll</Left>
<Right>lib/net472/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -169,27 +127,6 @@
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/net8.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net8.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -232,27 +169,6 @@
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/net9.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/net9.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.AgentInlineSkill.#ctor(Microsoft.Agents.AI.AgentSkillFrontmatter,System.String)</Target>
@@ -295,25 +211,4 @@
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.get_SimulateServiceStoredChatHistory</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Agents.AI.ChatClientAgentOptions.set_SimulateServiceStoredChatHistory(System.Boolean)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Microsoft.Extensions.AI.ChatClientBuilderExtensions.UseServiceStoredChatHistorySimulation(Microsoft.Extensions.AI.ChatClientBuilder)</Target>
<Left>lib/netstandard2.0/Microsoft.Agents.AI.dll</Left>
<Right>lib/netstandard2.0/Microsoft.Agents.AI.dll</Right>
<IsBaselineSuppression>true</IsBaselineSuppression>
</Suppression>
</Suppressions>
@@ -21,7 +21,7 @@ namespace Microsoft.Agents.AI;
/// </para>
/// <list type="bullet">
/// <item><description><strong>Mixed skill types</strong> — combine file-based, code-defined (<see cref="AgentInlineSkill"/>),
/// and class-based (<see cref="AgentClassSkill"/>) skills in a single provider.</description></item>
/// and class-based (<see cref="AgentClassSkill{TSelf}"/>) skills in a single provider.</description></item>
/// <item><description><strong>Multiple file script runners</strong> — use different script runners for different
/// file skill directories via per-source <c>scriptRunner</c> parameters on
/// <see cref="UseFileSkill"/> / <see cref="UseFileSkills(IEnumerable{string}, AgentFileSkillsSourceOptions?, AgentFileSkillScriptRunner?)"/>.</description></item>
@@ -32,15 +32,15 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private const string SkillFileName = "SKILL.md";
private const int MaxSearchDepth = 2;
// "." means the skill directory root itself (no sub-folder descent constraint)
private const string RootFolderIndicator = ".";
// "." means the skill directory root itself (no subdirectory descent constraint)
private const string RootDirectoryIndicator = ".";
private static readonly string[] s_defaultScriptExtensions = [".py", ".js", ".sh", ".ps1", ".cs", ".csx"];
private static readonly string[] s_defaultResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt"];
// Standard sub-folder names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptFolders = ["scripts"];
private static readonly string[] s_defaultResourceFolders = ["references", "assets"];
// Standard subdirectory names per https://agentskills.io/specification#directory-structure
private static readonly string[] s_defaultScriptDirectories = ["scripts"];
private static readonly string[] s_defaultResourceDirectories = ["references", "assets"];
// Matches YAML frontmatter delimited by "---" lines. Group 1 = content between delimiters.
// Multiline makes ^/$ match line boundaries; Singleline makes . match newlines across the block.
@@ -63,8 +63,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
private readonly IEnumerable<string> _skillPaths;
private readonly HashSet<string> _allowedResourceExtensions;
private readonly HashSet<string> _allowedScriptExtensions;
private readonly IReadOnlyList<string> _scriptFolders;
private readonly IReadOnlyList<string> _resourceFolders;
private readonly IReadOnlyList<string> _scriptDirectories;
private readonly IReadOnlyList<string> _resourceDirectories;
private readonly AgentFileSkillScriptRunner? _scriptRunner;
private readonly ILogger _logger;
@@ -111,13 +111,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
options?.AllowedScriptExtensions ?? s_defaultScriptExtensions,
StringComparer.OrdinalIgnoreCase);
this._scriptFolders = options?.ScriptFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ScriptFolders, this._logger)]
: s_defaultScriptFolders;
this._scriptDirectories = options?.ScriptDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ScriptDirectories, this._logger)]
: s_defaultScriptDirectories;
this._resourceFolders = options?.ResourceFolders is not null
? [.. ValidateAndNormalizeFolderNames(options.ResourceFolders, this._logger)]
: s_defaultResourceFolders;
this._resourceDirectories = options?.ResourceDirectories is not null
? [.. ValidateAndNormalizeDirectoryNames(options.ResourceDirectories, this._logger)]
: s_defaultResourceDirectories;
this._scriptRunner = scriptRunner;
}
@@ -303,12 +303,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans configured resource folders within a skill directory for resource files matching the configured extensions.
/// Scans configured resource directories within a skill directory for resource files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans <c>references/</c> and <c>assets/</c> sub-folders as specified by the
/// By default, scans <c>references/</c> and <c>assets/</c> subdirectories as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceFolders"/> to scan different or
/// Configure <see cref="AgentFileSkillsSourceOptions.ResourceDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
@@ -316,14 +316,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
var resources = new List<AgentFileSkillResource>();
foreach (string folder in this._resourceFolders.Distinct(StringComparer.OrdinalIgnoreCase))
foreach (string directory in this._resourceDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
@@ -331,13 +331,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogResourceSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
LogResourceSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
@@ -380,7 +380,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
// e.g. "references/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target folder.
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/references/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
@@ -416,12 +416,12 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Scans configured script folders within a skill directory for script files matching the configured extensions.
/// Scans configured script directories within a skill directory for script files matching the configured extensions.
/// </summary>
/// <remarks>
/// By default, scans the <c>scripts/</c> sub-folder as specified by the
/// By default, scans the <c>scripts/</c> subdirectory as specified by the
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>.
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptFolders"/> to scan different or
/// Configure <see cref="AgentFileSkillsSourceOptions.ScriptDirectories"/> to scan different or
/// additional directories, including <c>"."</c> for the skill root itself.
/// Each file is validated against path-traversal and symlink-escape checks; unsafe files are skipped.
/// </remarks>
@@ -429,14 +429,14 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
{
var scripts = new List<AgentFileSkillScript>();
foreach (string folder in this._scriptFolders.Distinct(StringComparer.OrdinalIgnoreCase))
foreach (string directory in this._scriptDirectories.Distinct(StringComparer.OrdinalIgnoreCase))
{
bool isRootFolder = string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal);
bool isRootDirectory = string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal);
// GetFullPath normalizes mixed separators (e.g. "C:\skill\scripts/f1" → "C:\skill\scripts\f1")
string targetDirectory = isRootFolder
string targetDirectory = isRootDirectory
? skillDirectoryFullPath
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, folder)) + Path.DirectorySeparatorChar;
: Path.GetFullPath(Path.Combine(skillDirectoryFullPath, directory)) + Path.DirectorySeparatorChar;
if (!Directory.Exists(targetDirectory))
{
@@ -444,13 +444,13 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
// Directory-level symlink check: skip if targetDirectory (or any intermediate
// segment) is a reparse point. The root folder is excluded — it's a caller-supplied
// segment) is a reparse point. The root directory is excluded — it's a caller-supplied
// trusted path, and the security boundary guards files within it, not the path itself.
if (!isRootFolder && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
if (!isRootDirectory && HasSymlinkInPath(targetDirectory, skillDirectoryFullPath))
{
if (this._logger.IsEnabled(LogLevel.Warning))
{
LogScriptSymlinkFolder(this._logger, skillName, SanitizePathForLog(folder));
LogScriptSymlinkDirectory(this._logger, skillName, SanitizePathForLog(directory));
}
continue;
@@ -480,7 +480,7 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
// e.g. "scripts/../../../etc/shadow" → "/etc/shadow"
string resolvedFilePath = Path.GetFullPath(filePath);
// Path containment: reject if the resolved path escapes the target folder.
// Path containment: reject if the resolved path escapes the target directory.
// e.g. "/etc/shadow".StartsWith("/skills/myskill/scripts/") → false → skip
if (!resolvedFilePath.StartsWith(targetDirectory, StringComparison.OrdinalIgnoreCase))
{
@@ -541,8 +541,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
/// <summary>
/// Normalizes a relative path or folder name by stripping a leading "./"/".\",
/// trimming trailing directory separators, and replacing backslashes with forward
/// Normalizes a relative path or directory name by stripping a leading "./"/".\",
/// trimming trailing separators, and replacing backslashes with forward
/// slashes.
/// </summary>
private static string NormalizePath(string path)
@@ -602,36 +602,36 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
}
}
private static IEnumerable<string> ValidateAndNormalizeFolderNames(IEnumerable<string> folders, ILogger logger)
private static IEnumerable<string> ValidateAndNormalizeDirectoryNames(IEnumerable<string> directories, ILogger logger)
{
foreach (string folder in folders)
foreach (string directory in directories)
{
if (string.IsNullOrWhiteSpace(folder))
if (string.IsNullOrWhiteSpace(directory))
{
throw new ArgumentException("Folder names must not be null or whitespace.", nameof(folders));
throw new ArgumentException("Directory names must not be null or whitespace.", nameof(directories));
}
// "." is valid — it means the skill root directory.
if (string.Equals(folder, RootFolderIndicator, StringComparison.Ordinal))
if (string.Equals(directory, RootDirectoryIndicator, StringComparison.Ordinal))
{
yield return folder;
yield return directory;
continue;
}
// Reject absolute paths and any path segments that escape upward.
if (Path.IsPathRooted(folder) || ContainsParentTraversalSegment(folder))
if (Path.IsPathRooted(directory) || ContainsParentTraversalSegment(directory))
{
LogFolderNameSkippedInvalid(logger, folder);
LogDirectoryNameSkippedInvalid(logger, directory);
continue;
}
yield return NormalizePath(folder);
yield return NormalizePath(directory);
}
}
private static bool ContainsParentTraversalSegment(string folder)
private static bool ContainsParentTraversalSegment(string directory)
{
foreach (string segment in folder.Split('/', '\\'))
foreach (string segment in directory.Split('/', '\\'))
{
if (segment == "..")
{
@@ -666,8 +666,8 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping resource in skill '{SkillName}': '{ResourcePath}' is a symlink that resolves outside the skill directory")]
private static partial void LogResourceSymlinkEscape(ILogger logger, string skillName, string resourcePath);
[LoggerMessage(LogLevel.Warning, "Skipping resource folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogResourceSymlinkFolder(ILogger logger, string skillName, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping resource directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogResourceSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Debug, "Skipping file '{FilePath}' in skill '{SkillName}': extension '{Extension}' is not in the allowed list")]
private static partial void LogResourceSkippedExtension(ILogger logger, string skillName, string filePath, string extension);
@@ -678,9 +678,9 @@ internal sealed partial class AgentFileSkillsSource : AgentSkillsSource
[LoggerMessage(LogLevel.Warning, "Skipping script in skill '{SkillName}': '{ScriptPath}' is a symlink that resolves outside the skill directory")]
private static partial void LogScriptSymlinkEscape(ILogger logger, string skillName, string scriptPath);
[LoggerMessage(LogLevel.Warning, "Skipping script folder '{FolderName}' in skill '{SkillName}': folder path contains a symlink")]
private static partial void LogScriptSymlinkFolder(ILogger logger, string skillName, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping script directory '{DirectoryName}' in skill '{SkillName}': directory path contains a symlink")]
private static partial void LogScriptSymlinkDirectory(ILogger logger, string skillName, string directoryName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid folder name '{FolderName}': must be a relative path with no '..' segments")]
private static partial void LogFolderNameSkippedInvalid(ILogger logger, string folderName);
[LoggerMessage(LogLevel.Warning, "Skipping invalid directory name '{DirectoryName}': must be a relative path with no '..' segments")]
private static partial void LogDirectoryNameSkippedInvalid(ILogger logger, string directoryName);
}
@@ -32,7 +32,7 @@ public sealed class AgentFileSkillsSourceOptions
public IEnumerable<string>? AllowedScriptExtensions { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for script files within each skill directory.
/// Gets or sets relative directory paths to scan for script files within each skill directory.
/// Values may be single-segment names (e.g., <c>"scripts"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/scripts"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
@@ -42,10 +42,10 @@ public sealed class AgentFileSkillsSourceOptions
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ScriptFolders { get; set; }
public IEnumerable<string>? ScriptDirectories { get; set; }
/// <summary>
/// Gets or sets relative folder paths to scan for resource files within each skill directory.
/// Gets or sets relative directory paths to scan for resource files within each skill directory.
/// Values may be single-segment names (e.g., <c>"references"</c>) or multi-segment relative
/// paths (e.g., <c>"sub/resources"</c>). Use <c>"."</c> to include files directly at the
/// skill root. Leading <c>"./"</c> prefixes, trailing separators, and backslashes are
@@ -55,5 +55,5 @@ public sealed class AgentFileSkillsSourceOptions
/// <see href="https://agentskills.io/specification">Agent Skills specification</see>).
/// When set, replaces the defaults entirely.
/// </summary>
public IEnumerable<string>? ResourceFolders { get; set; }
public IEnumerable<string>? ResourceDirectories { get; set; }
}
@@ -1,8 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
@@ -11,17 +15,55 @@ namespace Microsoft.Agents.AI;
/// <summary>
/// Abstract base class for defining skills as C# classes that bundle all components together.
/// </summary>
/// <typeparam name="TSelf">
/// The concrete skill type. This type parameter is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/> to ensure that the IL trimmer and Native AOT compiler
/// preserve the members needed for attribute-based discovery.
/// </typeparam>
/// <remarks>
/// <para>
/// Inherit from this class to create a self-contained skill definition. Override the abstract
/// properties to provide name, description, and instructions. Use <see cref="CreateResource(string, object, string?)"/>,
/// <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>, and <see cref="CreateScript"/> to define
/// inline resources and scripts.
/// properties to provide name, description, and instructions.
/// </para>
/// <para>
/// Scripts and resources can be defined in two ways:
/// <list type="bullet">
/// <item>
/// <b>Attribute-based (recommended):</b> Annotate methods with <see cref="AgentSkillScriptAttribute"/> to define scripts,
/// and properties or methods with <see cref="AgentSkillResourceAttribute"/> to define resources. These are automatically
/// discovered via reflection on <typeparamref name="TSelf"/>. This approach is compatible with Native AOT.
/// </item>
/// <item>
/// <b>Explicit override:</b> Override <see cref="AgentSkill.Resources"/> and <see cref="AgentSkill.Scripts"/>, using
/// <see cref="CreateResource(string, object, string?)"/>, <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/>,
/// and <see cref="CreateScript"/> to define inline resources and scripts. This approach is also compatible with Native AOT.
/// </item>
/// </list>
/// </para>
/// <para>
/// <b>Multi-level inheritance limitation:</b> Discovery reflects only on <typeparamref name="TSelf"/>,
/// so if a further-derived subclass adds new attributed members, they will not be discovered unless
/// that subclass also uses the CRTP pattern
/// (e.g., <c>class SpecialSkill : AgentClassSkill&lt;SpecialSkill&gt;</c>).
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class PdfFormatterSkill : AgentClassSkill
/// // Attribute-based approach (recommended, AOT-compatible):
/// public class PdfFormatterSkill : AgentClassSkill&lt;PdfFormatterSkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("pdf-formatter", "Format documents as PDF.");
/// protected override string Instructions =&gt; "Use this skill to format documents...";
///
/// [AgentSkillResource("template")]
/// public string Template =&gt; "Use this template...";
///
/// [AgentSkillScript("format-pdf")]
/// private static string FormatPdf(string content) =&gt; content;
/// }
///
/// // Explicit override approach (AOT-compatible):
/// public class ExplicitPdfFormatterSkill : AgentClassSkill&lt;ExplicitPdfFormatterSkill&gt;
/// {
/// private IReadOnlyList&lt;AgentSkillResource&gt;? _resources;
/// private IReadOnlyList&lt;AgentSkillScript&gt;? _scripts;
@@ -44,15 +86,41 @@ namespace Microsoft.Agents.AI;
/// </code>
/// </example>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public abstract class AgentClassSkill : AgentSkill
public abstract class AgentClassSkill<
[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicProperties |
DynamicallyAccessedMemberTypes.NonPublicProperties |
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TSelf>
: AgentSkill
where TSelf : AgentClassSkill<TSelf>
{
private const BindingFlags DiscoveryBindingFlags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
private string? _content;
private bool _resourcesDiscovered;
private bool _scriptsDiscovered;
private IReadOnlyList<AgentSkillResource>? _reflectedResources;
private IReadOnlyList<AgentSkillScript>? _reflectedScripts;
/// <summary>
/// Gets the raw instructions text for this skill.
/// </summary>
protected abstract string Instructions { get; }
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> used to marshal parameters and return values
/// for scripts and resources.
/// </summary>
/// <remarks>
/// Override this property to provide custom serialization options. This value is used by
/// reflection-discovered scripts and resources, and also as a fallback by <see cref="CreateScript"/>
/// and <see cref="CreateResource(string, Delegate, string?, JsonSerializerOptions?)"/> when no
/// explicit <see cref="JsonSerializerOptions"/> is passed to those methods.
/// The default value is <see langword="null"/>, which causes <see cref="AIJsonUtilities.DefaultOptions"/> to be used.
/// </remarks>
protected virtual JsonSerializerOptions? SerializerOptions => null;
/// <inheritdoc/>
/// <remarks>
/// Returns a synthesized XML document containing name, description, instructions, resources, and scripts.
@@ -65,6 +133,48 @@ public abstract class AgentClassSkill : AgentSkill
this.Resources,
this.Scripts);
/// <inheritdoc/>
/// <remarks>
/// Returns resources discovered via reflection by scanning <typeparamref name="TSelf"/> for
/// members annotated with <see cref="AgentSkillResourceAttribute"/>. This discovery is
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
/// </remarks>
public override IReadOnlyList<AgentSkillResource>? Resources
{
get
{
if (!this._resourcesDiscovered)
{
this._reflectedResources = this.DiscoverResources();
this._resourcesDiscovered = true;
}
return this._reflectedResources;
}
}
/// <inheritdoc/>
/// <remarks>
/// Returns scripts discovered via reflection by scanning <typeparamref name="TSelf"/> for
/// methods annotated with <see cref="AgentSkillScriptAttribute"/>. This discovery is
/// compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
/// </remarks>
public override IReadOnlyList<AgentSkillScript>? Scripts
{
get
{
if (!this._scriptsDiscovered)
{
this._reflectedScripts = this.DiscoverScripts();
this._scriptsDiscovered = true;
}
return this._reflectedScripts;
}
}
/// <summary>
/// Creates a skill resource backed by a static value.
/// </summary>
@@ -72,7 +182,7 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
protected static AgentSkillResource CreateResource(string name, object value, string? description = null)
protected AgentSkillResource CreateResource(string name, object value, string? description = null)
=> new AgentInlineSkillResource(name, value, description);
/// <summary>
@@ -83,11 +193,11 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="description">An optional description of the resource.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
/// </param>
/// <returns>A new <see cref="AgentSkillResource"/> instance.</returns>
protected static AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillResource(name, method, description, serializerOptions);
protected AgentSkillResource CreateResource(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillResource(name, method, description, serializerOptions ?? this.SerializerOptions);
/// <summary>
/// Creates a skill script backed by a delegate.
@@ -97,9 +207,129 @@ public abstract class AgentClassSkill : AgentSkill
/// <param name="description">An optional description of the script.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the delegate's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// When <see langword="null"/>, falls back to <see cref="SerializerOptions"/>.
/// </param>
/// <returns>A new <see cref="AgentSkillScript"/> instance.</returns>
protected static AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillScript(name, method, description, serializerOptions);
protected AgentSkillScript CreateScript(string name, Delegate method, string? description = null, JsonSerializerOptions? serializerOptions = null)
=> new AgentInlineSkillScript(name, method, description, serializerOptions ?? this.SerializerOptions);
private List<AgentSkillResource>? DiscoverResources()
{
List<AgentSkillResource>? resources = null;
var selfType = typeof(TSelf);
// Discover resources from properties annotated with [AgentSkillResource].
foreach (var property in selfType.GetProperties(DiscoveryBindingFlags))
{
var attr = property.GetCustomAttribute<AgentSkillResourceAttribute>();
if (attr is null)
{
continue;
}
var getter = property.GetGetMethod(nonPublic: true);
if (getter is null)
{
continue;
}
// Indexer properties have getter parameters and cannot be used as resources
// because ReadAsync invokes the underlying AIFunction with no named arguments.
if (getter.GetParameters().Length > 0)
{
throw new InvalidOperationException(
$"Property '{property.Name}' on type '{selfType.Name}' is an indexer and cannot be used as a skill resource. " +
"Remove the [AgentSkillResource] attribute or use a non-indexer property.");
}
var name = attr.Name ?? property.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: getter,
target: getter.IsStatic ? null : this,
description: property.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
// Discover resources from methods annotated with [AgentSkillResource].
foreach (var method in selfType.GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute<AgentSkillResourceAttribute>();
if (attr is null)
{
continue;
}
ValidateResourceMethodParameters(method, selfType);
var name = attr.Name ?? method.Name;
if (resources?.Exists(r => r.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a resource named '{name}'. Ensure each [AgentSkillResource] has a unique name.");
}
resources ??= [];
resources.Add(new AgentInlineSkillResource(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
return resources;
}
private static void ValidateResourceMethodParameters(MethodInfo method, Type skillType)
{
foreach (var param in method.GetParameters())
{
if (param.ParameterType != typeof(IServiceProvider) &&
param.ParameterType != typeof(CancellationToken))
{
throw new InvalidOperationException(
$"Method '{method.Name}' on type '{skillType.Name}' has parameter '{param.Name}' of type " +
$"'{param.ParameterType}' which cannot be supplied when reading a resource. " +
"Resource methods may only accept IServiceProvider and/or CancellationToken parameters. " +
"Remove the [AgentSkillResource] attribute or change the method signature.");
}
}
}
private List<AgentSkillScript>? DiscoverScripts()
{
List<AgentSkillScript>? scripts = null;
foreach (var method in typeof(TSelf).GetMethods(DiscoveryBindingFlags))
{
var attr = method.GetCustomAttribute<AgentSkillScriptAttribute>();
if (attr is null)
{
continue;
}
var name = attr.Name ?? method.Name;
if (scripts?.Exists(s => s.Name == name) == true)
{
throw new InvalidOperationException($"Skill '{this.Frontmatter.Name}' already has a script named '{name}'. Ensure each [AgentSkillScript] has a unique name.");
}
scripts ??= [];
scripts.Add(new AgentInlineSkillScript(
name: name,
method: method,
target: method.IsStatic ? null : this,
description: method.GetCustomAttribute<DescriptionAttribute>()?.Description,
serializerOptions: this.SerializerOptions));
}
return scripts;
}
}
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -54,6 +55,28 @@ internal sealed class AgentInlineSkillResource : AgentSkillResource
this._function = AIFunctionFactory.Create(method, options);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillResource"/> class from a <see cref="MethodInfo"/>.
/// The method is invoked via an <see cref="AIFunction"/> each time <see cref="ReadAsync"/> is called,
/// producing a dynamic (computed) value.
/// </summary>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="target">The target instance for instance methods, or <see langword="null"/> for static methods.</param>
/// <param name="description">An optional description of the resource.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the method's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// </param>
public AgentInlineSkillResource(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null)
: base(name, description)
{
Throw.IfNull(method);
var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions };
this._function = AIFunctionFactory.Create(method, target, options);
}
/// <inheritdoc/>
public override async Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
{
@@ -2,6 +2,7 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -39,6 +40,27 @@ internal sealed class AgentInlineSkillScript : AgentSkillScript
this._function = AIFunctionFactory.Create(method, options);
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentInlineSkillScript"/> class from a <see cref="MethodInfo"/>.
/// The method's parameters and return type are automatically marshaled via <see cref="AIFunctionFactory"/>.
/// </summary>
/// <param name="name">The script name.</param>
/// <param name="method">The method to execute when the script is invoked.</param>
/// <param name="target">The target instance for instance methods, or <see langword="null"/> for static methods.</param>
/// <param name="description">An optional description of the script.</param>
/// <param name="serializerOptions">
/// Optional <see cref="JsonSerializerOptions"/> used to marshal the method's parameters and return value.
/// When <see langword="null"/>, <see cref="AIJsonUtilities.DefaultOptions"/> is used.
/// </param>
public AgentInlineSkillScript(string name, MethodInfo method, object? target, string? description = null, JsonSerializerOptions? serializerOptions = null)
: base(Throw.IfNullOrWhitespace(name), description)
{
Throw.IfNull(method);
var options = new AIFunctionFactoryOptions { Name = this.Name, SerializerOptions = serializerOptions };
this._function = AIFunctionFactory.Create(method, target, options);
}
/// <summary>
/// Gets the JSON schema describing the parameters accepted by this script, or <see langword="null"/> if not available.
/// </summary>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Marks a property or method as a skill resource that is automatically discovered by <see cref="AgentClassSkill{TSelf}"/>.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to properties or methods in an <see cref="AgentClassSkill{TSelf}"/> subclass to register
/// them as skill resources.
/// </para>
/// <para>
/// To provide a description for the resource, apply <see cref="DescriptionAttribute"/>
/// to the same member.
/// </para>
/// <para>
/// When applied to a <b>property</b>, the property getter is invoked each time the resource is read,
/// enabling dynamic (computed) resources. When applied to a <b>method</b>, the method is invoked each time
/// the resource is read, also enabling dynamic resources. Methods with an
/// <see cref="IServiceProvider"/> parameter support dependency injection.
/// </para>
/// <para>
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
/// Alternatively, override the <see cref="AgentSkill.Resources"/> property and use
/// <see cref="AgentClassSkill{TSelf}.CreateResource(string, object, string?)"/> instead.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class MySkill : AgentClassSkill&lt;MySkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
/// protected override string Instructions =&gt; "Use this skill to do something.";
///
/// [AgentSkillResource("reference-data")]
/// [Description("Some reference content for the skill.")]
/// public string ReferenceData =&gt; "Some reference content.";
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillResourceAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillResourceAttribute"/> class.
/// The resource name defaults to the property or method name.
/// </summary>
public AgentSkillResourceAttribute()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillResourceAttribute"/> class
/// with an explicit resource name.
/// </summary>
/// <param name="name">The resource name used to identify this resource.</param>
public AgentSkillResourceAttribute(string name)
{
this.Name = name;
}
/// <summary>
/// Gets the resource name, or <see langword="null"/> to use the member name.
/// </summary>
public string? Name { get; }
}
@@ -0,0 +1,72 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI;
/// <summary>
/// Marks a method as a skill script that is automatically discovered by <see cref="AgentClassSkill{TSelf}"/>.
/// </summary>
/// <remarks>
/// <para>
/// Apply this attribute to methods in an <see cref="AgentClassSkill{TSelf}"/> subclass to register them as
/// skill scripts. The method's parameters and return type are automatically marshaled via
/// <c>AIFunctionFactory</c>.
/// </para>
/// <para>
/// To provide a description for the script, apply <see cref="DescriptionAttribute"/>
/// to the same method.
/// </para>
/// <para>
/// Methods can be instance or static, and may have any visibility (public, private, etc.).
/// Methods with an <see cref="IServiceProvider"/> parameter support dependency injection.
/// </para>
/// <para>
/// This attribute is compatible with Native AOT when used with <see cref="AgentClassSkill{TSelf}"/>.
/// Alternatively, override the <see cref="AgentSkill.Scripts"/> property and use
/// <see cref="AgentClassSkill{TSelf}.CreateScript"/> instead.
/// </para>
/// </remarks>
/// <example>
/// <code>
/// public class MySkill : AgentClassSkill&lt;MySkill&gt;
/// {
/// public override AgentSkillFrontmatter Frontmatter { get; } = new("my-skill", "A skill.");
/// protected override string Instructions =&gt; "Use this skill to do something.";
///
/// [AgentSkillScript("do-something")]
/// [Description("Converts the input to upper case.")]
/// private static string DoSomething(string input) =&gt; input.ToUpperInvariant();
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AgentSkillScriptAttribute : Attribute
{
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillScriptAttribute"/> class.
/// The script name defaults to the method name.
/// </summary>
public AgentSkillScriptAttribute()
{
}
/// <summary>
/// Initializes a new instance of the <see cref="AgentSkillScriptAttribute"/> class
/// with an explicit script name.
/// </summary>
/// <param name="name">The script name used to identify this script.</param>
public AgentSkillScriptAttribute(string name)
{
this.Name = name;
}
/// <summary>
/// Gets the script name, or <see langword="null"/> to use the method name.
/// </summary>
public string? Name { get; }
}
@@ -21,6 +21,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
@@ -0,0 +1,226 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.AGUI;
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.AGUI.AspNetCore.IntegrationTests;
public sealed class SessionPersistenceTests : IAsyncDisposable
{
private WebApplication? _app;
private HttpClient? _client;
[Fact]
public async Task MultiTurnWithSessionStore_PersistsSessionAcrossRequestsAsync()
{
// Arrange - use hosting DI pattern with InMemorySessionStore.
// FakeSessionAgent tracks turn count in session StateBag so we can verify
// that state survives the serialization round-trip through the session store.
await this.SetupTestServerWithSessionStoreAsync();
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
// Act - First turn
ChatMessage firstUserMessage = new(ChatRole.User, "First message");
List<AgentResponseUpdate> firstTurnUpdates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([firstUserMessage], session, new AgentRunOptions(), CancellationToken.None))
{
firstTurnUpdates.Add(update);
}
// Act - Second turn (same thread ID to test session persistence)
ChatMessage secondUserMessage = new(ChatRole.User, "Second message");
List<AgentResponseUpdate> secondTurnUpdates = [];
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([secondUserMessage], session, new AgentRunOptions(), CancellationToken.None))
{
secondTurnUpdates.Add(update);
}
// Assert - Verify turn count proves session state was persisted.
// If session persistence were broken, both turns would return "Turn 1"
// because a fresh session (with turn count 0) would be created each time.
AgentResponse firstResponse = firstTurnUpdates.ToAgentResponse();
firstResponse.Messages.Should().HaveCount(1);
firstResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
firstResponse.Messages[0].Text.Should().Contain("Turn 1:");
AgentResponse secondResponse = secondTurnUpdates.ToAgentResponse();
secondResponse.Messages.Should().HaveCount(1);
secondResponse.Messages[0].Role.Should().Be(ChatRole.Assistant);
secondResponse.Messages[0].Text.Should().Contain("Turn 2:");
}
[Fact]
public async Task MapAGUI_WithAgentName_StreamsResponseCorrectlyAsync()
{
// Arrange - use the MapAGUI(agentName, pattern) overload via hosting DI
await this.SetupTestServerWithSessionStoreAsync();
var chatClient = new AGUIChatClient(this._client!, "", null);
AIAgent agent = chatClient.AsAIAgent(instructions: null, name: "assistant", description: "Sample assistant", tools: []);
ChatClientAgentSession session = (ChatClientAgentSession)await agent.CreateSessionAsync();
ChatMessage userMessage = new(ChatRole.User, "hello");
List<AgentResponseUpdate> updates = [];
// Act
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync([userMessage], session, new AgentRunOptions(), CancellationToken.None))
{
updates.Add(update);
}
// Assert
updates.Should().NotBeEmpty();
updates.Should().AllSatisfy(u => u.Role.Should().Be(ChatRole.Assistant));
AgentResponse response = updates.ToAgentResponse();
response.Messages.Should().HaveCount(1);
response.Messages[0].Role.Should().Be(ChatRole.Assistant);
response.Messages[0].Text.Should().Be("Turn 1: Hello from session agent!");
}
private async Task SetupTestServerWithSessionStoreAsync()
{
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddAGUI();
// Register agent using hosting DI pattern with InMemorySessionStore
builder.Services.AddAIAgent("session-test-agent", (_, name) => new FakeSessionAgent(name))
.WithInMemorySessionStore();
this._app = builder.Build();
// Use the agentName overload of MapAGUI
this._app.MapAGUI("session-test-agent", "/agent");
await this._app.StartAsync();
TestServer testServer = this._app.Services.GetRequiredService<IServer>() as TestServer
?? throw new InvalidOperationException("TestServer not found");
this._client = testServer.CreateClient();
this._client.BaseAddress = new Uri("http://localhost/agent");
}
public async ValueTask DisposeAsync()
{
this._client?.Dispose();
if (this._app != null)
{
await this._app.DisposeAsync();
}
}
}
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via dependency injection")]
internal sealed class FakeSessionAgent : AIAgent
{
private readonly string _name;
public FakeSessionAgent(string name)
{
this._name = name;
}
protected override string? IdCore => this._name;
public override string? Name => this._name;
public override string? Description => "A fake agent with session support for testing";
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new FakeSessionAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
new(serializedState.Deserialize<FakeSessionAgentSession>(jsonSerializerOptions)!);
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
if (session is not FakeSessionAgentSession fakeSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent.");
}
return new(JsonSerializer.SerializeToElement(fakeSession, jsonSerializerOptions));
}
protected override async Task<AgentResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
}
return updates.ToAgentResponse();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Track turn count in session state to enable persistence verification.
// If the session store works correctly, the turn count increments across requests.
int turnCount = 1;
if (session != null)
{
var counter = session.StateBag.GetValue<TurnCounter>("turnCounter");
turnCount = (counter?.Count ?? 0) + 1;
session.StateBag.SetValue("turnCounter", new TurnCounter { Count = turnCount });
}
string messageId = Guid.NewGuid().ToString("N");
string prefix = $"Turn {turnCount}: ";
foreach (string chunk in new[] { prefix, "Hello", " ", "from", " ", "session", " ", "agent", "!" })
{
yield return new AgentResponseUpdate
{
MessageId = messageId,
Role = ChatRole.Assistant,
Contents = [new TextContent(chunk)]
};
await Task.Yield();
}
}
internal sealed class TurnCounter
{
public int Count { get; set; }
}
private sealed class FakeSessionAgentSession : AgentSession
{
public FakeSessionAgentSession()
{
}
[JsonConstructor]
public FakeSessionAgentSession(AgentSessionStateBag stateBag) : base(stateBag)
{
}
}
}
@@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -31,6 +32,7 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
@@ -45,6 +47,155 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
Assert.NotNull(result);
}
[Fact]
public void MapAGUI_WithAgentName_ResolvesKeyedAgentFromDI()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
AIAgent agent = new NamedTestAgent();
serviceProviderMock.As<IKeyedServiceProvider>()
.Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
.Returns(agent);
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("test-agent", "/api/agent");
// Assert
Assert.NotNull(result);
serviceProviderMock.As<IKeyedServiceProvider>()
.Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
}
[Fact]
public void MapAGUI_WithHostedAgentBuilder_ResolvesAgentByBuilderName()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
Mock<IHostedAgentBuilder> agentBuilderMock = new();
AIAgent agent = new NamedTestAgent();
agentBuilderMock.Setup(b => b.Name).Returns("test-agent");
serviceProviderMock.As<IKeyedServiceProvider>()
.Setup(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"))
.Returns(agent);
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI(agentBuilderMock.Object, "/api/agent");
// Assert
Assert.NotNull(result);
serviceProviderMock.As<IKeyedServiceProvider>()
.Verify(sp => sp.GetRequiredKeyedService(typeof(AIAgent), "test-agent"), Times.Once);
}
[Fact]
public void MapAGUI_WithAgent_ResolvesSessionStoreFromDI()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
Mock<AgentSessionStore> sessionStoreMock = new();
AIAgent agent = new NamedTestAgent();
serviceProviderMock.As<IKeyedServiceProvider>()
.Setup(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"))
.Returns(sessionStoreMock.Object);
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
// Act
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
// Assert
Assert.NotNull(result);
serviceProviderMock.As<IKeyedServiceProvider>()
.Verify(sp => sp.GetKeyedService(typeof(AgentSessionStore), "test-agent"), Times.Once);
}
[Fact]
public void MapAGUI_WithoutSessionStore_FallsBackToNoopStore()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
AIAgent agent = new TestAgent();
// No session store registered - IKeyedServiceProvider returns null by default
serviceProviderMock.As<IKeyedServiceProvider>();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
endpointsMock.Setup(e => e.DataSources).Returns([]);
// Act - should not throw (falls back to NoopAgentSessionStore)
IEndpointConventionBuilder? result = endpointsMock.Object.MapAGUI("/api/agent", agent);
// Assert
Assert.NotNull(result);
}
[Fact]
public void MapAGUI_WithNullEndpoints_ThrowsArgumentNullException()
{
// Arrange
AIAgent agent = new TestAgent();
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
AGUIEndpointRouteBuilderExtensions.MapAGUI(null!, "/api/agent", agent));
}
[Fact]
public void MapAGUI_WithNullAgent_ThrowsArgumentNullException()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
endpointsMock.Object.MapAGUI("/api/agent", (AIAgent)null!));
}
[Fact]
public void MapAGUI_WithNullAgentName_ThrowsArgumentNullException()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
serviceProviderMock.As<IKeyedServiceProvider>();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
endpointsMock.Object.MapAGUI((string)null!, "/api/agent"));
}
[Fact]
public void MapAGUI_WithNullAgentBuilder_ThrowsArgumentNullException()
{
// Arrange
Mock<IEndpointRouteBuilder> endpointsMock = new();
Mock<IServiceProvider> serviceProviderMock = new();
endpointsMock.Setup(e => e.ServiceProvider).Returns(serviceProviderMock.Object);
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
endpointsMock.Object.MapAGUI((IHostedAgentBuilder)null!, "/api/agent"));
}
[Fact]
public async Task MapAGUIAgent_WithNullOrInvalidInput_Returns400BadRequestAsync()
{
@@ -556,4 +707,44 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
}
}
private sealed class NamedTestAgent : AIAgent
{
protected override string? IdCore => "test-agent";
public override string? Name => "test-agent";
public override string? Description => "Named test agent";
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default) =>
new(new TestAgentSession());
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) =>
new(serializedState.Deserialize<TestAgentSession>(jsonSerializerOptions)!);
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
if (session is not TestAgentSession testSession)
{
throw new InvalidOperationException($"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(TestAgentSession)}' can be serialized by this agent.");
}
return new(JsonSerializer.SerializeToElement(testSession, jsonSerializerOptions));
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session = null,
AgentRunOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield return new AgentResponseUpdate(new ChatResponseUpdate(ChatRole.Assistant, "Test response"));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -116,8 +116,8 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
[Fact]
public async Task GetSkillsAsync_ScriptsOutsideScriptsDir_AreNotDiscoveredAsync()
{
// Arrange — scripts outside configured folders are not discovered; only files directly
// inside the configured folder are picked up (no subdirectory recursion)
// Arrange — scripts outside configured directories are not discovered; only files directly
// inside the configured directory are picked up (no subdirectory recursion)
string skillDir = CreateSkillDir(this._testRoot, "root-scripts", "Root scripts skill", "Body.");
CreateFile(skillDir, "convert.py", "print('root')");
CreateFile(skillDir, "tools/helper.sh", "echo 'helper'");
@@ -126,7 +126,7 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — neither file is in the default scripts/ folder, so no scripts are discovered
// Assert — neither file is in the default scripts/ directory, so no scripts are discovered
Assert.Single(skills);
Assert.Empty(skills[0].Scripts!);
}
@@ -229,18 +229,18 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptFoldersWithNestedPath_DiscoversScriptsAsync()
public async Task GetSkillsAsync_ScriptDirectoriesWithNestedPath_DiscoversScriptsAsync()
{
// Arrange — ScriptFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script folder", "Body.");
// Arrange — ScriptDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = CreateSkillDir(this._testRoot, "nested-script-skill", "Nested script directory", "Body.");
CreateFile(skillDir, "f1/f2/f3/run.py", "print('nested')");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — script file inside the deeply nested folder is discovered
// Assert — script file inside the deeply nested directory is discovered
Assert.Single(skills);
Assert.Single(skills[0].Scripts!);
Assert.Equal("f1/f2/f3/run.py", skills[0].Scripts![0].Name);
@@ -250,29 +250,29 @@ public sealed class AgentFileSkillsSourceScriptTests : IDisposable
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("./scripts/f1", "./f2")]
public async Task GetSkillsAsync_ScriptFolderWithDotSlashPrefix_DiscoversScriptsAsync(params string[] folders)
public async Task GetSkillsAsync_ScriptDirectoryWithDotSlashPrefix_DiscoversScriptsAsync(params string[] directories)
{
// Arrange — "./"-prefixed folders are equivalent to their counterparts without the prefix;
// Arrange — "./"-prefixed directories are equivalent to their counterparts without the prefix;
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string skillDir = CreateSkillDir(this._testRoot, "dotslash-script-skill", "Dot-slash prefix", "Body.");
foreach (string folder in folders)
foreach (string directory in directories)
{
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
CreateFile(skillDir, $"{folderWithoutDotSlash}/run.py", "print('dotslash')");
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
CreateFile(skillDir, $"{directoryWithoutDotSlash}/run.py", "print('dotslash')");
}
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = folders });
new AgentFileSkillsSourceOptions { ScriptDirectories = directories });
// Act
var skills = await source.GetSkillsAsync(CancellationToken.None);
// Assert — scripts are discovered with names identical to using folders without "./"
// Assert — scripts are discovered with names identical to using directories without "./"
Assert.Single(skills);
Assert.Equal(folders.Length, skills[0].Scripts!.Count);
foreach (string folder in folders)
Assert.Equal(directories.Length, skills[0].Scripts!.Count);
foreach (string directory in directories)
{
string expectedName = $"{folder.Substring(2)}/run.py";
string expectedName = $"{directory.Substring(2)}/run.py";
Assert.Contains(skills[0].Scripts!, s => s.Name == expectedName);
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
@@ -167,4 +168,58 @@ public sealed class AgentInlineSkillResourceTests
// Assert
Assert.Equal("value", result);
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var resource = new AgentInlineSkillResource("method-resource", method, target: null, description: "A method resource.");
// Assert
Assert.Equal("method-resource", resource.Name);
Assert.Equal("A method resource.", resource.Description);
}
[Fact]
public async Task ReadAsync_MethodInfo_StaticMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(StaticResourceHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var resource = new AgentInlineSkillResource("static-method-res", method, target: null);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("static-resource-value", result?.ToString());
}
[Fact]
public async Task ReadAsync_MethodInfo_InstanceMethod_ReturnsValueAsync()
{
// Arrange
var method = typeof(AgentInlineSkillResourceTests).GetMethod(nameof(InstanceResourceHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var resource = new AgentInlineSkillResource("instance-method-res", method, target: this);
// Act
var result = await resource.ReadAsync();
// Assert
Assert.Equal("instance-resource-value", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillResource("my-res", null!, target: null));
}
private static string StaticResourceHelper() => "static-resource-value";
private string InstanceResourceHelper() => "instance-resource-value";
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -152,4 +153,77 @@ public sealed class AgentInlineSkillScriptTests
// Assert
Assert.Equal("hello world", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_SetsNameAndDescription()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
// Act
var script = new AgentInlineSkillScript("method-script", method, target: null, description: "A method script.");
// Assert
Assert.Equal("method-script", script.Name);
Assert.Equal("A method script.", script.Description);
}
[Fact]
public async Task RunAsync_MethodInfo_StaticMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("static-method-script", method, target: null);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "hello" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("HELLO", result?.ToString());
}
[Fact]
public async Task RunAsync_MethodInfo_InstanceMethod_InvokesAndReturnsAsync()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(InstanceScriptHelper), BindingFlags.NonPublic | BindingFlags.Instance)!;
var script = new AgentInlineSkillScript("instance-method-script", method, target: this);
var skill = new AgentInlineSkill("test-skill", "Test.", "Instructions.");
var args = new AIFunctionArguments { ["input"] = "test" };
// Act
var result = await script.RunAsync(skill, args, CancellationToken.None);
// Assert
Assert.Equal("test-suffix", result?.ToString());
}
[Fact]
public void Constructor_MethodInfo_NullMethod_Throws()
{
// Act & Assert
Assert.Throws<ArgumentNullException>(() =>
new AgentInlineSkillScript("my-script", null!, target: null));
}
[Fact]
public void ParametersSchema_MethodInfo_ContainsParameterNames()
{
// Arrange
var method = typeof(AgentInlineSkillScriptTests).GetMethod(nameof(StaticScriptHelper), BindingFlags.NonPublic | BindingFlags.Static)!;
var script = new AgentInlineSkillScript("param-script", method, target: null);
// Act
var schema = script.ParametersSchema;
// Assert
Assert.NotNull(schema);
Assert.Contains("input", schema!.Value.GetRawText());
}
private static string StaticScriptHelper(string input) => input.ToUpperInvariant();
private string InstanceScriptHelper(string input) => input + "-suffix";
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillResourceAttribute"/>.
/// </summary>
public sealed class AgentSkillResourceAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillResourceAttribute("my-resource");
// Assert
Assert.Equal("my-resource", attr.Name);
}
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentSkillScriptAttribute"/>.
/// </summary>
public sealed class AgentSkillScriptAttributeTests
{
[Fact]
public void DefaultConstructor_NameIsNull()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute();
// Assert
Assert.Null(attr.Name);
}
[Fact]
public void NamedConstructor_SetsName()
{
// Arrange & Act
var attr = new AgentSkillScriptAttribute("my-script");
// Assert
Assert.Equal("my-script", attr.Name);
}
}
@@ -871,7 +871,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
public async Task Constructor_ClassSkillsEnumerable_ProvidesSkillsAsync()
{
// Arrange
var skills = new List<AgentClassSkill>
var skills = new List<AgentSkill>
{
new TestClassSkill("enum-class-a", "Class A", "Instructions A."),
new TestClassSkill("enum-class-b", "Class B", "Instructions B."),
@@ -928,7 +928,7 @@ public sealed class AgentSkillsProviderTests : IDisposable
}
}
private sealed class TestClassSkill : AgentClassSkill
private sealed class TestClassSkill : AgentClassSkill<TestClassSkill>
{
private readonly string _instructions;
@@ -199,7 +199,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_FilesWithMatchingExtensions_DiscoveredAsResourcesAsync()
{
// Arrange — create resource files in spec-defined sub-folders
// Arrange — create resource files in spec-defined subdirectories
string skillDir = Path.Combine(this._testRoot, "resource-skill");
string refsDir = Path.Combine(skillDir, "references");
string assetsDir = Path.Combine(skillDir, "assets");
@@ -226,7 +226,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_FilesWithNonMatchingExtensions_NotDiscoveredAsync()
{
// Arrange — create a file with an extension not in the default list inside a spec folder
// Arrange — create a file with an extension not in the default list inside a spec directory
string skillDir = Path.Combine(this._testRoot, "ext-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -300,7 +300,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_CustomResourceExtensions_UsedForDiscoveryAsync()
{
// Arrange — use a source with custom extensions; files placed in spec folder
// Arrange — use a source with custom extensions; files placed in spec directory
string skillDir = Path.Combine(this._testRoot, "custom-ext-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -364,7 +364,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_NotDiscoveredByDefaultAsync()
{
// Arrange — resource files directly in the skill directory (not in a spec sub-folder)
// Arrange — resource files directly in the skill directory (not in a spec subdirectory)
string skillDir = Path.Combine(this._testRoot, "root-resource-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -377,15 +377,15 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
// Act
var skills = await source.GetSkillsAsync();
// Assert — root-level files are NOT discovered unless "." is in ResourceFolders
// Assert — root-level files are NOT discovered unless "." is in ResourceDirectories
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
public async Task GetSkillsAsync_ResourceInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — "." in ResourceFolders opts into root-level resource discovery
// Arrange — "." in ResourceDirectories opts into root-level resource discovery
string skillDir = Path.Combine(this._testRoot, "root-opt-in-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "guide.md"), "guide content");
@@ -394,7 +394,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-opt-in-skill\ndescription: Root opt-in\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "assets", "."] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "assets", "."] });
// Act
var skills = await source.GetSkillsAsync();
@@ -408,31 +408,31 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ResourceInNonSpecFolder_NotDiscoveredByDefaultAsync()
public async Task GetSkillsAsync_ResourceInNonSpecDirectory_NotDiscoveredByDefaultAsync()
{
// Arrange — resource in a non-spec folder (neither references/ nor assets/)
// Arrange — resource in a non-spec directory (neither references/ nor assets/)
string skillDir = Path.Combine(this._testRoot, "non-spec-skill");
string customDir = Path.Combine(skillDir, "docs");
Directory.CreateDirectory(customDir);
File.WriteAllText(Path.Combine(customDir, "readme.md"), "docs content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: non-spec-skill\ndescription: Non-spec folder\n---\nBody.");
"---\nname: non-spec-skill\ndescription: Non-spec directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — non-spec folders are not scanned by default
// Assert — non-spec directories are not scanned by default
Assert.Single(skills);
Assert.Empty(skills[0].Resources!);
}
[Fact]
public async Task GetSkillsAsync_CustomResourceFolders_ReplacesDefaultsAsync()
public async Task GetSkillsAsync_CustomResourceDirectories_ReplacesDefaultsAsync()
{
// Arrange — custom ResourceFolders replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-folder-skill");
// Arrange — custom ResourceDirectories replaces the spec defaults
string skillDir = Path.Combine(this._testRoot, "custom-directory-skill");
string customDir = Path.Combine(skillDir, "docs");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(customDir);
@@ -441,9 +441,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(Path.Combine(refsDir, "ref.md"), "ref content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: custom-folder-skill\ndescription: Custom folder\n---\nBody.");
"---\nname: custom-directory-skill\ndescription: Custom directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["docs"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["docs"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -518,7 +518,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Fact]
public async Task ReadSkillResourceAsync_ValidResource_ReturnsContentAsync()
{
// Arrange — create a skill with a resource file discovered from the references folder
// Arrange — create a skill with a resource file discovered from the references directory
string skillDir = this.CreateSkillDirectory("read-skill", "A skill", "See docs for details.");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
@@ -614,12 +614,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_SymlinkedResourceFolder_SkipsWithoutEnumeratingAsync()
public async Task GetSkillsAsync_SymlinkedResourceDirectory_SkipsWithoutEnumeratingAsync()
{
// Arrange — references/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely (no file enumeration),
// so even files with valid extensions in the target are not discovered.
string skillDir = Path.Combine(this._testRoot, "symlink-folder-skip");
string skillDir = Path.Combine(this._testRoot, "symlink-directory-skip");
string assetsDir = Path.Combine(skillDir, "assets");
Directory.CreateDirectory(assetsDir);
File.WriteAllText(Path.Combine(assetsDir, "legit.md"), "legit content");
@@ -642,21 +642,21 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-folder-skip\ndescription: Symlinked folder skip\n---\nBody.");
"---\nname: symlink-directory-skip\ndescription: Symlinked directory skip\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — only assets/legit.md is found; the symlinked references/ folder is skipped entirely
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-folder-skip");
// Assert — only assets/legit.md is found; the symlinked references/ directory is skipped entirely
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-directory-skip");
Assert.NotNull(skill);
Assert.Single(skill.Resources!);
Assert.Equal("assets/legit.md", skill.Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedScriptFolder_SkipsWithoutEnumeratingAsync()
public async Task GetSkillsAsync_SymlinkedScriptDirectory_SkipsWithoutEnumeratingAsync()
{
// Arrange — scripts/ is a symlink pointing outside the skill directory.
// The directory-level check should skip it entirely.
@@ -679,22 +679,22 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: symlink-script-skip\ndescription: Symlinked script folder\n---\nBody.");
"---\nname: symlink-script-skip\ndescription: Symlinked script directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor);
// Act
var skills = await source.GetSkillsAsync();
// Assert — skill loads but scripts from the symlinked folder are not discovered
// Assert — skill loads but scripts from the symlinked directory are not discovered
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-script-skip");
Assert.NotNull(skill);
Assert.Empty(skill.Scripts!);
}
[Fact]
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomFolderAsync()
public async Task GetSkillsAsync_SymlinkedIntermediateSegment_SkipsCustomDirectoryAsync()
{
// Arrange — custom resource folder "sub/resources" where "sub" is a symlink.
// Arrange — custom resource directory "sub/resources" where "sub" is a symlink.
// The directory-level HasSymlinkInPath check should detect the intermediate symlink.
string skillDir = Path.Combine(this._testRoot, "symlink-intermediate");
Directory.CreateDirectory(skillDir);
@@ -720,12 +720,12 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
var source = new AgentFileSkillsSource(
this._testRoot,
s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["sub/resources"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["sub/resources"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the symlinked intermediate segment causes the folder to be skipped
// Assert — the symlinked intermediate segment causes the directory to be skipped
var skill = skills.FirstOrDefault(s => s.Frontmatter.Name == "symlink-intermediate");
Assert.NotNull(skill);
Assert.Empty(skill.Resources!);
@@ -900,11 +900,11 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[InlineData("sub/../escape")]
[InlineData("/absolute")]
[InlineData("\\absolute")]
public void Constructor_InvalidFolderName_SkipsInvalidFolders(string badFolder)
public void Constructor_InvalidDirectoryName_SkipsInvalidDirectories(string badDirectory)
{
// Arrange & Act — invalid folders are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder] });
// Arrange & Act — invalid directories are skipped with a warning rather than throwing
var source1 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory] });
var source2 = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory] });
// Assert
Assert.NotNull(source1);
@@ -915,54 +915,54 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Constructor_NullOrWhitespaceFolderName_ThrowsArgumentException(string? badFolder)
public void Constructor_NullOrWhitespaceDirectoryName_ThrowsArgumentException(string? badDirectory)
{
// Arrange & Act & Assert — null/whitespace is a contract violation, not a config error
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [badFolder!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceFolders = [badFolder!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [badDirectory!] }));
Assert.Throws<ArgumentException>(() => new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ResourceDirectories = [badDirectory!] }));
}
[Theory]
[InlineData("scripts")]
[InlineData("my-scripts")]
[InlineData("sub/folder")]
[InlineData("sub/directory")]
[InlineData(".")]
[InlineData("./scripts")]
[InlineData("./scripts/f1")]
[InlineData("my..scripts")]
public void Constructor_ValidFolderName_DoesNotThrow(string validFolder)
public void Constructor_ValidDirectoryName_DoesNotThrow(string validDirectory)
{
// Arrange & Act & Assert
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptFolders = [validFolder] });
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor, new AgentFileSkillsSourceOptions { ScriptDirectories = [validDirectory] });
Assert.NotNull(source);
}
[Fact]
public async Task GetSkillsAsync_DuplicateFoldersAfterNormalization_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_DuplicateDirectoriesAfterNormalization_NoDuplicateResourcesAsync()
{
// Arrange — "references" and "./references" refer to the same directory;
// after normalization they should be deduplicated so resources appear only once.
string skillDir = Path.Combine(this._testRoot, "dedup-folder-skill");
string skillDir = Path.Combine(this._testRoot, "dedup-directory-skill");
string refsDir = Path.Combine(skillDir, "references");
Directory.CreateDirectory(refsDir);
File.WriteAllText(Path.Combine(refsDir, "FAQ.md"), "FAQ content");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dedup-folder-skill\ndescription: Dedup test\n---\nBody.");
"---\nname: dedup-directory-skill\ndescription: Dedup test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "./references"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "./references"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — only one copy of the resource despite two equivalent folder entries
// Assert — only one copy of the resource despite two equivalent directory entries
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal("references/FAQ.md", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_TrailingSlashFolderNormalized_NoDuplicateResourcesAsync()
public async Task GetSkillsAsync_TrailingSlashDirectoryNormalized_NoDuplicateResourcesAsync()
{
// Arrange — "references/" should be normalized to "references"
string skillDir = Path.Combine(this._testRoot, "trailing-slash-skill");
@@ -973,7 +973,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: trailing-slash-skill\ndescription: Trailing slash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["references", "references/"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["references", "references/"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -985,7 +985,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_BackslashFolderNormalized_NoDuplicateScriptsAsync()
public async Task GetSkillsAsync_BackslashDirectoryNormalized_NoDuplicateScriptsAsync()
{
// Arrange — ".\\scripts" should be normalized to "scripts"
string skillDir = Path.Combine(this._testRoot, "backslash-skill");
@@ -996,7 +996,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: backslash-skill\ndescription: Backslash test\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["scripts", ".\\scripts"] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["scripts", ".\\scripts"] });
// Act
var skills = await source.GetSkillsAsync();
@@ -1010,48 +1010,48 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
[Theory]
[InlineData("./references")]
[InlineData("./assets/docs")]
public async Task GetSkillsAsync_ResourceFolderWithDotSlashPrefix_DiscoversResourcesAsync(string folder)
public async Task GetSkillsAsync_ResourceDirectoryWithDotSlashPrefix_DiscoversResourcesAsync(string directory)
{
// Arrange — "./references" and "./assets/docs" are equivalent to "references" and "assets/docs";
// the leading "./" is transparently normalized by Path.GetFullPath during file enumeration.
string folderWithoutDotSlash = folder.Substring(2); // strip "./"
string directoryWithoutDotSlash = directory.Substring(2); // strip "./"
string skillDir = Path.Combine(this._testRoot, "dotslash-res-skill");
string targetDir = Path.Combine(skillDir, folderWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
string targetDir = Path.Combine(skillDir, directoryWithoutDotSlash.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(targetDir);
File.WriteAllText(Path.Combine(targetDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: dotslash-res-skill\ndescription: Dot-slash prefix\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = [folder] });
new AgentFileSkillsSourceOptions { ResourceDirectories = [directory] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — the resource is discovered with a name identical to using the folder without "./"
// Assert — the resource is discovered with a name identical to using the directory without "./"
Assert.Single(skills);
Assert.Single(skills[0].Resources!);
Assert.Equal($"{folderWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
Assert.Equal($"{directoryWithoutDotSlash}/data.json", skills[0].Resources![0].Name);
}
[Fact]
public async Task GetSkillsAsync_ResourceFoldersWithNestedPath_DiscoversResourcesAsync()
public async Task GetSkillsAsync_ResourceDirectoriesWithNestedPath_DiscoversResourcesAsync()
{
// Arrange — ResourceFolders configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-folder-skill");
// Arrange — ResourceDirectories configured with a multi-segment relative path (f1/f2/f3)
string skillDir = Path.Combine(this._testRoot, "nested-directory-skill");
string nestedDir = Path.Combine(skillDir, "f1", "f2", "f3");
Directory.CreateDirectory(nestedDir);
File.WriteAllText(Path.Combine(nestedDir, "data.json"), "{}");
File.WriteAllText(
Path.Combine(skillDir, "SKILL.md"),
"---\nname: nested-folder-skill\ndescription: Nested folder\n---\nBody.");
"---\nname: nested-directory-skill\ndescription: Nested directory\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ResourceFolders = ["f1/f2/f3"] });
new AgentFileSkillsSourceOptions { ResourceDirectories = ["f1/f2/f3"] });
// Act
var skills = await source.GetSkillsAsync();
// Assert — resource file inside the deeply nested folder is discovered
// Assert — resource file inside the deeply nested directory is discovered
Assert.Single(skills);
var skill = skills[0];
Assert.Single(skill.Resources!);
@@ -1107,9 +1107,9 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
}
[Fact]
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootFolderConfiguredAsync()
public async Task GetSkillsAsync_ScriptInSkillRoot_DiscoveredWhenRootDirectoryConfiguredAsync()
{
// Arrange — script file directly in the skill directory with ScriptFolders = ["."]
// Arrange — script file directly in the skill directory with ScriptDirectories = ["."]
string skillDir = Path.Combine(this._testRoot, "root-script-skill");
Directory.CreateDirectory(skillDir);
File.WriteAllText(Path.Combine(skillDir, "run.py"), "print('hello')");
@@ -1117,7 +1117,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
Path.Combine(skillDir, "SKILL.md"),
"---\nname: root-script-skill\ndescription: Root script\n---\nBody.");
var source = new AgentFileSkillsSource(this._testRoot, s_noOpExecutor,
new AgentFileSkillsSourceOptions { ScriptFolders = ["."] });
new AgentFileSkillsSourceOptions { ScriptDirectories = ["."] });
// Act
var skills = await source.GetSkillsAsync();
@@ -1131,7 +1131,7 @@ public sealed class FileAgentSkillLoaderTests : IDisposable
#if NET
[Fact]
public async Task GetSkillsAsync_SymlinkedFileInRealFolder_SkipsSymlinkedFileAsync()
public async Task GetSkillsAsync_SymlinkedFileInRealDirectory_SkipsSymlinkedFileAsync()
{
// Arrange — references/ is a real directory, but one file inside it is a symlink
// pointing outside the skill directory. The per-file symlink check should skip it.
@@ -9,6 +9,7 @@ using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
@@ -147,7 +148,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(numAgents + 1, result.Count);
@@ -225,7 +226,7 @@ public class AgentWorkflowBuilderTests
barrier.Value = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
remaining.Value = 2;
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.NotEmpty(updateText);
Assert.NotNull(result);
@@ -258,7 +259,7 @@ public class AgentWorkflowBuilderTests
}), description: "nop"))
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent1", updateText);
Assert.NotNull(result);
@@ -296,7 +297,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(initialAgent, nextAgent)
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent2", updateText);
Assert.NotNull(result);
@@ -406,7 +407,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Contains("Hello from agent3", updateText);
@@ -604,7 +605,7 @@ public class AgentWorkflowBuilderTests
.WithHandoff(secondAgent, thirdAgent)
.Build();
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
@@ -634,6 +635,232 @@ public class AgentWorkflowBuilderTests
Assert.Contains("thirdAgent", result[5].AuthorName);
}
[Fact]
public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Only a handoff function call.
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
bool secondAgentInvoked = false;
const string SomeOtherFunctionCallId = "call2first";
AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction));
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
if (!secondAgentInvoked)
{
secondAgentInvoked = true;
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)]));
}
// Second agent should receive the conversation so far (including previous assistant + tool messages eventually).
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]);
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
(string updateText, List<ChatMessage>? result, CheckpointInfo? lastCheckpoint, List<RequestInfoEvent> requests) =
await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager);
Assert.Null(result);
Assert.NotNull(requests);
requests.Should().HaveCount(1);
ExternalRequest request = requests[0].Request;
ToolApprovalRequestContent approvalRequest =
request.Data.As<ToolApprovalRequestContent>().Should().NotBeNull()
.And.Subject.As<ToolApprovalRequestContent>();
approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId);
ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied"));
(updateText, result, _, requests) =
await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
// User + (assistant empty + tool) for each of first two agents + final assistant with text.
Assert.Equal(10, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
// Non-handoff tool invocation (and user denial)
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("", result[3].Text);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Equal(ChatRole.User, result[4].Role);
Assert.Equal("", result[4].Text);
// Rejected tool call
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Equal("", result[5].Text);
Assert.Contains("secondAgent", result[5].AuthorName);
Assert.Equal(ChatRole.Tool, result[6].Role);
Assert.Contains("secondAgent", result[6].AuthorName);
// Handoff invocation
Assert.Equal(ChatRole.Assistant, result[7].Role);
Assert.Equal("", result[7].Text);
Assert.Contains("secondAgent", result[7].AuthorName);
Assert.Equal(ChatRole.Tool, result[8].Role);
Assert.Contains("secondAgent", result[8].AuthorName);
Assert.Equal(ChatRole.Assistant, result[9].Role);
Assert.Equal("Hello from agent3", result[9].Text);
Assert.Contains("thirdAgent", result[9].AuthorName);
static bool SomeOtherFunction() => true;
}
[Fact]
public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync()
{
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
ChatMessage message = Assert.Single(messages);
Assert.Equal("abc", Assert.IsType<TextContent>(Assert.Single(message.Contents)).Text);
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
// Only a handoff function call.
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)]));
}), name: "initialAgent");
bool secondAgentInvoked = false;
const string SomeOtherFunctionName = "SomeOtherFunction";
const string SomeOtherFunctionCallId = "call2first";
JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema;
AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema);
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
if (!secondAgentInvoked)
{
secondAgentInvoked = true;
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)]));
}
// Second agent should receive the conversation so far (including previous assistant + tool messages eventually).
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)]));
}), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]);
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
CheckpointManager checkpointManager = CheckpointManager.CreateInMemory();
const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep;
(string updateText, List<ChatMessage>? result, CheckpointInfo? lastCheckpoint, List<RequestInfoEvent> requests) =
await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager);
Assert.Null(result);
Assert.NotNull(requests);
requests.Should().HaveCount(1);
ExternalRequest request = requests[0].Request;
FunctionCallContent functionCall = request.Data.As<FunctionCallContent>().Should().NotBeNull()
.And.Subject.As<FunctionCallContent>();
functionCall.CallId.Should().Be(SomeOtherFunctionCallId);
functionCall.Name.Should().Be(SomeOtherFunctionName);
ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true));
(updateText, result, _, requests) =
await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint);
Assert.Equal("Hello from agent3", updateText);
Assert.NotNull(result);
// User + (assistant empty + tool) for each of first two agents + final assistant with text.
Assert.Equal(8, result.Count);
Assert.Equal(ChatRole.User, result[0].Role);
Assert.Equal("abc", result[0].Text);
Assert.Equal(ChatRole.Assistant, result[1].Role);
Assert.Equal("", result[1].Text);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Equal(ChatRole.Tool, result[2].Role);
Assert.Contains("initialAgent", result[2].AuthorName);
// Non-handoff tool invocation
Assert.Equal(ChatRole.Assistant, result[3].Role);
Assert.Equal("", result[3].Text);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Equal(ChatRole.Tool, result[4].Role);
Assert.Contains("secondAgent", result[4].AuthorName);
// Handoff invocation
Assert.Equal(ChatRole.Assistant, result[5].Role);
Assert.Equal("", result[5].Text);
Assert.Contains("secondAgent", result[5].AuthorName);
Assert.Equal(ChatRole.Tool, result[6].Role);
Assert.Contains("secondAgent", result[6].AuthorName);
Assert.Equal(ChatRole.Assistant, result[7].Role);
Assert.Equal("Hello from agent3", result[7].Text);
Assert.Contains("thirdAgent", result[7].AuthorName);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
@@ -651,7 +878,7 @@ public class AgentWorkflowBuilderTests
for (int iter = 0; iter < 3; iter++)
{
const string UserInput = "abc";
(string updateText, List<ChatMessage>? result, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
(string updateText, List<ChatMessage>? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, UserInput)]);
Assert.NotNull(result);
Assert.Equal(maxIterations + 1, result.Count);
@@ -832,7 +1059,7 @@ public class AgentWorkflowBuilderTests
Assert.Equal(1, specialistCallCount); // specialist NOT called
}
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint);
private sealed record WorkflowRunResult(string UpdateText, List<ChatMessage>? Result, CheckpointInfo? LastCheckpoint, List<RequestInfoEvent> PendingRequests);
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
@@ -843,6 +1070,15 @@ public class AgentWorkflowBuilderTests
return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint);
}
private static Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null)
{
InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment()
.WithCheckpointing(checkpointManager);
return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint);
}
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, List<ChatMessage> input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
@@ -853,15 +1089,39 @@ public class AgentWorkflowBuilderTests
await run.TrySendMessageAsync(input);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
return await ProcessWorkflowRunAsync(run);
}
private static async Task<WorkflowRunResult> RunWorkflowCheckpointedAsync(
Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null)
{
await using StreamingRun run =
fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint)
: await environment.OpenStreamingAsync(workflow);
await run.SendResponseAsync(response);
return await ProcessWorkflowRunAsync(run);
}
private static async Task<WorkflowRunResult> ProcessWorkflowRunAsync(StreamingRun run)
{
StringBuilder sb = new();
WorkflowOutputEvent? output = null;
CheckpointInfo? lastCheckpoint = null;
await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
List<RequestInfoEvent> pendingRequests = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
{
switch (evt)
{
case AgentResponseUpdateEvent executorComplete:
sb.Append(executorComplete.Data);
case AgentResponseUpdateEvent responseUpdate:
sb.Append(responseUpdate.Data);
break;
case RequestInfoEvent requestInfo:
pendingRequests.Add(requestInfo);
break;
case WorkflowOutputEvent e:
@@ -878,7 +1138,7 @@ public class AgentWorkflowBuilderTests
}
}
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint);
return new(sb.ToString(), output?.As<List<ChatMessage>>(), lastCheckpoint, pendingRequests);
}
private static Task<WorkflowRunResult> RunWorkflowAsync(
@@ -29,7 +29,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
emitAgentResponseUpdateEvents: executorSetting,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(agent, options);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
@@ -57,7 +57,7 @@ public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
emitAgentResponseUpdateEvents: false,
HandoffToolCallFilteringBehavior.None);
HandoffAgentExecutor executor = new(agent, options);
HandoffAgentExecutor executor = new(agent, [], options);
testContext.ConfigureExecutor(executor);
// Act
+1
View File
@@ -24,6 +24,7 @@
],
"words": [
"aeiou",
"agentserver",
"agui",
"aiplatform",
"azuredocindex",
+3
View File
@@ -38,6 +38,9 @@ COPILOTSTUDIOAGENT__AGENTAPPID=""
# Anthropic
ANTHROPIC_API_KEY=""
ANTHROPIC_MODEL=""
# Google Gemini
GEMINI_API_KEY=""
GEMINI_MODEL=""
# Ollama
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
@@ -1,5 +1,3 @@
# Copyright (c) Microsoft. All rights reserved.
---
name: python-feature-lifecycle
description: >
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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))
## [1.0.1] - 2026-04-09
### Added
+1
View File
@@ -31,6 +31,7 @@ Status is grouped into these buckets:
| `agent-framework-durabletask` | `python/packages/durabletask` | `beta` |
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
| `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-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
@@ -9,6 +9,7 @@ from ._client import AGUIChatClient
from ._endpoint import add_agent_framework_fastapi_endpoint
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
from ._state import state_update
from ._types import AgentState, AGUIChatOptions, AGUIRequest, PredictStateConfig, RunMetadata
from ._workflow import AgentFrameworkWorkflow, WorkflowFactory
@@ -34,5 +35,6 @@ __all__ = [
"PredictStateConfig",
"RunMetadata",
"DEFAULT_TAGS",
"state_update",
"__version__",
]
@@ -6,6 +6,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, cast
@@ -31,6 +32,7 @@ from ag_ui.core import (
from agent_framework import Content
from ._orchestration._predictive_state import PredictiveStateHandler
from ._state import TOOL_RESULT_STATE_KEY
from ._utils import generate_event_id, make_json_safe
logger = logging.getLogger(__name__)
@@ -233,16 +235,66 @@ def _emit_tool_call(
return events
def _extract_tool_result_state(content: Content) -> dict[str, Any] | None:
"""Extract a deterministic AG-UI state update from a tool-result ``Content``.
Tools using :func:`agent_framework_ag_ui.state_update` carry the state
payload in ``additional_properties[TOOL_RESULT_STATE_KEY]`` on the inner
text item produced by ``parse_result``. We also check the outer
function_result content's ``additional_properties`` for robustness.
If multiple items carry state, they are merged in order so later items
override earlier ones (plain ``dict.update`` semantics).
Returns:
The merged state dict to apply, or ``None`` if no state update is
present.
"""
merged: dict[str, Any] | None = None
outer_ap = getattr(content, "additional_properties", None) or {}
outer_state = outer_ap.get(TOOL_RESULT_STATE_KEY)
if isinstance(outer_state, dict):
merged = dict(outer_state)
for item in content.items or ():
item_ap = getattr(item, "additional_properties", None) or {}
item_state = item_ap.get(TOOL_RESULT_STATE_KEY)
if isinstance(item_state, dict):
if merged is None:
merged = dict(item_state)
else:
merged.update(item_state)
return merged
def _emit_tool_result_common(
call_id: str,
raw_result: Any,
flow: FlowState,
predictive_handler: PredictiveStateHandler | None = None,
*,
state_update: Mapping[str, Any] | None = None,
) -> list[BaseEvent]:
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result``
(MCP server tool results) delegate to this function.
Args:
call_id: Tool call identifier.
raw_result: The stringified tool result content sent back to the LLM.
flow: Current ``FlowState``.
predictive_handler: Optional predictive state handler driven by
``predict_state_config``.
state_update: Optional deterministic state snapshot produced by a tool
returning :func:`agent_framework_ag_ui.state_update`. When present,
it is merged into ``flow.current_state`` and a ``StateSnapshotEvent``
is emitted after the ``ToolCallResult`` event. When both
``predictive_handler`` and ``state_update`` are active, predictive
updates are applied first, then the deterministic merge, and a
single coalesced ``StateSnapshotEvent`` is emitted.
"""
events: list[BaseEvent] = []
@@ -271,8 +323,18 @@ def _emit_tool_result_common(
if predictive_handler:
predictive_handler.apply_pending_updates()
if flow.current_state:
events.append(StateSnapshotEvent(snapshot=flow.current_state))
if state_update:
flow.current_state.update(state_update)
logger.debug(
"Emitted deterministic tool-result StateSnapshotEvent for call_id=%s (keys=%s)",
call_id,
list(state_update.keys()),
)
# Emit a single coalesced snapshot when either mechanism updated state.
if (predictive_handler or state_update) and flow.current_state:
events.append(StateSnapshotEvent(snapshot=flow.current_state))
flow.tool_call_id = None
flow.tool_call_name = None
@@ -295,7 +357,14 @@ def _emit_tool_result(
if not content.call_id:
return []
raw_result = content.result if content.result is not None else ""
return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler)
state_update = _extract_tool_result_state(content)
return _emit_tool_result_common(
content.call_id,
raw_result,
flow,
predictive_handler,
state_update=state_update,
)
def _emit_approval_request(
@@ -460,7 +529,14 @@ def _emit_mcp_tool_result(
logger.warning("MCP tool result content missing call_id, skipping")
return []
raw_output = content.output if content.output is not None else ""
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
state_update = _extract_tool_result_state(content)
return _emit_tool_result_common(
content.call_id,
raw_output,
flow,
predictive_handler,
state_update=state_update,
)
def _close_reasoning_block(flow: FlowState) -> list[BaseEvent]:
@@ -0,0 +1,84 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deterministic tool-driven AG-UI state updates.
Tools wired into the :mod:`agent_framework_ag_ui` endpoint can push a
deterministic state update by returning :func:`state_update`. Unlike
``predict_state_config`` — which emits ``StateDeltaEvent``s optimistically from
LLM-predicted tool call arguments — ``state_update`` runs *after* the tool
executes, so the AG-UI state always reflects the tool's actual return value.
See issue https://github.com/microsoft/agent-framework/issues/3167 for the
motivating discussion.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from agent_framework import Content
__all__ = ["TOOL_RESULT_STATE_KEY", "state_update"]
TOOL_RESULT_STATE_KEY = "__ag_ui_tool_result_state__"
"""Reserved ``Content.additional_properties`` key used to carry a tool-driven
state snapshot from a tool return value through to the AG-UI emitter."""
def state_update(
text: str = "",
*,
state: Mapping[str, Any],
) -> Content:
"""Build a tool return value that deterministically updates AG-UI shared state.
Return the result of this helper from an agent tool to push a state update
to AG-UI clients using the actual tool output, rather than LLM-predicted
tool arguments.
When the AG-UI endpoint emits the tool result, it will:
* Forward ``text`` to the LLM as the normal ``function_result`` content.
* Merge ``state`` into ``FlowState.current_state``.
* Emit a deterministic ``StateSnapshotEvent`` after the ``ToolCallResult``
event so frontends observe the updated state deterministically. If
predictive state is enabled, a predictive snapshot may be emitted first.
Example:
.. code-block:: python
from agent_framework import tool
from agent_framework_ag_ui import state_update
@tool
async def get_weather(city: str) -> Content:
data = await _fetch_weather(city)
return state_update(
text=f"Weather in {city}: {data['temp']}°C {data['conditions']}",
state={"weather": {"city": city, **data}},
)
Args:
text: Text passed back to the LLM as the ``function_result`` content.
Defaults to an empty string for tools whose only output is a state
update.
state: A mapping merged into the AG-UI shared state via JSON-compatible
``dict.update`` semantics. Nested dicts are replaced, not deep-merged.
Returns:
A ``Content`` object with ``type="text"``. The state payload rides in
``additional_properties`` under :data:`TOOL_RESULT_STATE_KEY` and is
extracted by the AG-UI emitter.
Raises:
TypeError: If ``state`` is not a ``Mapping``.
"""
if not isinstance(state, Mapping):
raise TypeError(f"state_update() 'state' must be a Mapping, got {type(state).__name__}")
return Content.from_text(
text,
additional_properties={TOOL_RESULT_STATE_KEY: dict(state)},
)
@@ -0,0 +1,92 @@
# Copyright (c) Microsoft. All rights reserved.
"""Deterministic tool-driven AG-UI state example.
This sample demonstrates how a tool can push a *deterministic* state update
to the AG-UI frontend based on its actual return value — in contrast to
``predict_state_config`` which fires optimistically from LLM-predicted tool
call arguments. See issue https://github.com/microsoft/agent-framework/issues/3167.
The :func:`agent_framework_ag_ui.state_update` helper wraps a text result
together with a state snapshot. When a tool returns one of these, the AG-UI
endpoint merges the snapshot into the shared state and emits a
``StateSnapshotEvent`` after the tool result.
"""
from __future__ import annotations
from typing import Any
from agent_framework import Agent, Content, SupportsChatGetResponse, tool
from agent_framework.ag_ui import AgentFrameworkAgent
from agent_framework_ag_ui import state_update
# Simulated weather database — in the issue's motivating example the tool
# would instead call a real weather API.
_WEATHER_DB: dict[str, dict[str, Any]] = {
"seattle": {"temperature": 11, "conditions": "rainy", "humidity": 75},
"san francisco": {"temperature": 14, "conditions": "foggy", "humidity": 85},
"new york city": {"temperature": 18, "conditions": "sunny", "humidity": 60},
"miami": {"temperature": 29, "conditions": "hot and humid", "humidity": 90},
"chicago": {"temperature": 9, "conditions": "windy", "humidity": 65},
}
@tool
async def get_weather(location: str) -> Content:
"""Fetch current weather for a location and push it into AG-UI shared state.
Unlike ``predict_state_config`` — which derives state optimistically from
LLM-predicted tool call arguments — this tool uses ``state_update`` to
forward the *actual* fetched weather to the frontend. The ``text`` goes
back to the LLM as the normal tool result, and the ``state`` dict is merged
into the AG-UI shared state.
Args:
location: City name to look up.
Returns:
A :class:`Content` carrying both the LLM-visible text result and a
deterministic state snapshot.
"""
key = location.lower()
data = _WEATHER_DB.get(
key,
{"temperature": 21, "conditions": "partly cloudy", "humidity": 50},
)
weather_record = {"location": location, **data}
return state_update(
text=(
f"The weather in {location} is {data['conditions']} at "
f"{data['temperature']}°C with {data['humidity']}% humidity."
),
state={"weather": weather_record},
)
def weather_state_agent(client: SupportsChatGetResponse[Any]) -> AgentFrameworkAgent:
"""Create an AG-UI agent with a deterministic tool-driven state tool."""
agent = Agent[Any](
name="weather_state_agent",
instructions=(
"You are a weather assistant. When a user asks about the weather "
"in a city, call the get_weather tool and use its output to give a "
"friendly, concise reply. The tool also updates the shared UI state "
"so the frontend can render a weather card from the `weather` key."
),
client=client,
tools=[get_weather],
)
return AgentFrameworkAgent(
agent=agent,
name="WeatherStateAgent",
description="Weather agent that deterministically updates shared state from tool results.",
state_schema={
"weather": {
"type": "object",
"description": "Last fetched weather record",
},
},
)
@@ -24,6 +24,7 @@ from ..agents.subgraphs_agent import subgraphs_agent
from ..agents.task_steps_agent import task_steps_agent_wrapped
from ..agents.ui_generator_agent import ui_generator_agent
from ..agents.weather_agent import weather_agent
from ..agents.weather_state_agent import weather_state_agent
AnthropicClient: type[Any] | None
try:
@@ -141,6 +142,14 @@ add_agent_framework_fastapi_endpoint(
path="/subgraphs",
)
# Deterministic Tool-Driven State - tool returns state_update() to push snapshot
# from actual tool output (see issue #3167).
add_agent_framework_fastapi_endpoint(
app=app,
agent=weather_state_agent(client),
path="/deterministic_state",
)
def main():
"""Run the server."""
@@ -0,0 +1,267 @@
# Copyright (c) Microsoft. All rights reserved.
"""Golden event-stream tests for the deterministic tool-driven state scenario.
Covers issue https://github.com/microsoft/agent-framework/issues/3167 — a tool
returning :func:`agent_framework_ag_ui.state_update` must push a deterministic
``StateSnapshotEvent`` derived from its actual return value, orthogonal to the
optimistic ``predict_state_config`` path. These golden tests pin the user-visible
event stream so additive changes cannot silently regress it.
"""
from __future__ import annotations
from typing import Any
from agent_framework import AgentResponseUpdate, Content
from conftest import StubAgent
from event_stream import EventStream
from agent_framework_ag_ui import AgentFrameworkAgent, state_update
STATE_SCHEMA = {
"weather": {"type": "object", "description": "Last fetched weather"},
}
def _build_agent(updates: list[AgentResponseUpdate], **kwargs: Any) -> AgentFrameworkAgent:
stub = StubAgent(updates=updates)
kwargs.setdefault("state_schema", STATE_SCHEMA)
return AgentFrameworkAgent(agent=stub, **kwargs)
async def _run(agent: AgentFrameworkAgent, payload: dict[str, Any]) -> EventStream:
return EventStream([event async for event in agent.run(payload)])
PAYLOAD: dict[str, Any] = {
"thread_id": "thread-det-state",
"run_id": "run-det-state",
"messages": [{"role": "user", "content": "What's the weather in SF?"}],
"state": {"weather": {}},
}
def _tool_call(call_id: str, name: str, arguments: str) -> AgentResponseUpdate:
return AgentResponseUpdate(
contents=[Content.from_function_call(name=name, call_id=call_id, arguments=arguments)],
role="assistant",
)
def _tool_result_with_state(call_id: str, text: str, state: dict[str, Any]) -> AgentResponseUpdate:
"""Build a function_result update whose inner item carries a state marker.
This mirrors what the core framework produces when a real ``@tool`` returns
:func:`state_update`: ``parse_result`` keeps the ``Content`` as-is, and
``Content.from_function_result`` preserves its ``additional_properties``
inside ``items``.
"""
return AgentResponseUpdate(
contents=[
Content.from_function_result(
call_id=call_id,
result=[state_update(text=text, state=state)],
)
],
role="assistant",
)
# ── Golden stream tests ──
async def test_deterministic_state_emits_snapshot_after_tool_result() -> None:
"""The happy path: STATE_SNAPSHOT follows TOOL_CALL_RESULT in order."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather in SF: 14°C foggy",
state={"weather": {"city": "SF", "temp": 14, "conditions": "foggy"}},
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 14°C and foggy in SF.")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
stream.assert_text_messages_balanced()
# Ordered subsequence: the deterministic STATE_SNAPSHOT must follow the
# TOOL_CALL_RESULT. This is the central contract for #3167.
stream.assert_ordered_types(
[
"RUN_STARTED",
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"STATE_SNAPSHOT",
"RUN_FINISHED",
]
)
# The final STATE_SNAPSHOT must carry the tool-driven state.
snapshot = stream.snapshot()
assert snapshot["weather"] == {"city": "SF", "temp": 14, "conditions": "foggy"}
async def test_deterministic_state_does_not_fire_for_plain_tool_result() -> None:
"""Regression guard: tools returning plain strings must NOT emit a new STATE_SNAPSHOT.
The initial STATE_SNAPSHOT fires once from the schema + initial payload
state. A plain (non-state_update) tool result must not add another one.
"""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
AgentResponseUpdate(
contents=[Content.from_function_result(call_id="call-1", result="14°C foggy")],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_text(text="It's 14°C and foggy.")],
role="assistant",
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_no_run_error()
snapshots = stream.get("STATE_SNAPSHOT")
# Only the initial snapshot (from state_schema + payload state) should exist.
# No deterministic snapshot should have been added by the plain tool result.
assert len(snapshots) == 1, (
f"Expected exactly 1 STATE_SNAPSHOT (initial only) for plain tool result; "
f"got {len(snapshots)}. Snapshots: {[s.snapshot for s in snapshots]}"
)
async def test_deterministic_state_merges_into_initial_state() -> None:
"""The tool-driven snapshot must merge into, not replace, pre-existing state keys."""
payload = dict(PAYLOAD)
payload["state"] = {"weather": {}, "user_preferences": {"unit": "C"}}
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather: 14°C",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(updates, state_schema={**STATE_SCHEMA, "user_preferences": {"type": "object"}})
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_no_run_error()
final_snapshot = stream.snapshot()
assert final_snapshot["weather"] == {"city": "SF", "temp": 14}
assert final_snapshot["user_preferences"] == {"unit": "C"}, (
"Pre-existing state keys must survive the deterministic merge"
)
async def test_deterministic_state_llm_visible_text_is_clean() -> None:
"""The LLM-visible TOOL_CALL_RESULT content must not leak the state marker key."""
updates = [
_tool_call("call-1", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-1",
text="Weather in SF: 14°C foggy",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(updates)
stream = await _run(agent, PAYLOAD)
result = stream.first("TOOL_CALL_RESULT")
assert result.content == "Weather in SF: 14°C foggy"
# The marker key must never appear in the content sent back to the LLM.
assert "__ag_ui_tool_result_state__" not in result.content
assert "weather" not in result.content # not as a raw state dump
async def test_deterministic_state_multiple_tools_merge_in_order() -> None:
"""Two state-updating tools in one run merge in order; later wins on key collisions."""
updates = [
_tool_call("call-a", "get_weather", '{"city": "SF"}'),
_tool_result_with_state(
"call-a",
text="First result",
state={"weather": {"city": "SF", "temp": 14}, "source": "primary"},
),
_tool_call("call-b", "get_weather_refined", '{"city": "SF"}'),
_tool_result_with_state(
"call-b",
text="Refined result",
state={"source": "refined"},
),
AgentResponseUpdate(
contents=[Content.from_text(text="Here you go.")],
role="assistant",
),
]
agent = _build_agent(
updates,
state_schema={**STATE_SCHEMA, "source": {"type": "string"}},
)
stream = await _run(agent, PAYLOAD)
stream.assert_bookends()
stream.assert_tool_calls_balanced()
stream.assert_no_run_error()
# Two tool-driven snapshots emitted (one per tool) plus the initial snapshot.
snapshots = stream.get("STATE_SNAPSHOT")
assert len(snapshots) >= 2, f"Expected at least 2 STATE_SNAPSHOTs; got {len(snapshots)}"
final = stream.snapshot()
assert final["weather"] == {"city": "SF", "temp": 14}
# Later tool must override earlier tool on the shared key.
assert final["source"] == "refined"
async def test_deterministic_state_coexists_with_predict_state_config() -> None:
"""Predictive state and deterministic state must coexist without clobbering each other."""
predict_config = {
"draft": {
"tool": "write_draft",
"tool_argument": "body",
}
}
updates = [
# Predictive tool: its argument "body" populates state.draft optimistically.
_tool_call("call-1", "write_draft", '{"body": "Hello world"}'),
# Then a deterministic tool result landing a different key.
_tool_result_with_state(
"call-1",
text="Draft saved",
state={"weather": {"city": "SF", "temp": 14}},
),
]
agent = _build_agent(
updates,
state_schema={**STATE_SCHEMA, "draft": {"type": "string"}},
predict_state_config=predict_config,
require_confirmation=False,
)
payload = dict(PAYLOAD)
payload["state"] = {"weather": {}, "draft": ""}
stream = await _run(agent, payload)
stream.assert_bookends()
stream.assert_no_run_error()
stream.assert_tool_calls_balanced()
# The final observed state must contain both the deterministic and predictive contributions.
final = stream.snapshot()
assert final["weather"] == {"city": "SF", "temp": 14}, f"Deterministic state missing from final snapshot: {final}"
@@ -1405,3 +1405,95 @@ async def test_fabricated_rejection_without_pending_approval_is_blocked(streamin
for content in msg.contents:
if content.type == "function_result" and content.call_id == "fake_reject_001":
assert False, "Fabricated rejection response leaked as function_result into LLM messages"
async def test_state_update_end_to_end_via_real_tool_invocation(streaming_chat_client_stub):
"""End-to-end coverage for issue #3167: a real ``@tool`` returning ``state_update`` must
emit a deterministic STATE_SNAPSHOT through the full pipeline.
This test exercises the entire chain that a user would hit in production:
``FunctionInvocationLayer`` executes the tool, ``FunctionTool.parse_result``
preserves the returned ``Content`` with its ``additional_properties`` marker,
``Content.from_function_result`` carries the marker through in ``items``,
and the AG-UI emitter extracts it via ``_extract_tool_result_state`` and
emits the snapshot. A regression anywhere in that chain will fail this test.
"""
from agent_framework import tool
from agent_framework.ag_ui import AgentFrameworkAgent
from agent_framework_ag_ui import state_update
@tool(name="get_weather", description="Get current weather for a city.")
async def get_weather(city: str) -> Content:
return state_update(
text=f"Weather in {city}: 14°C foggy",
state={"weather": {"city": city, "temperature": 14, "conditions": "foggy"}},
)
call_count = {"n": 0}
async def stream_fn(
messages: MutableSequence[Message], options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
"""First turn proposes a tool call; second turn (after tool execution) returns text."""
call_count["n"] += 1
if call_count["n"] == 1:
yield ChatResponseUpdate(
contents=[
Content.from_function_call(
name="get_weather",
call_id="call-weather-1",
arguments='{"city": "SF"}',
)
]
)
else:
yield ChatResponseUpdate(contents=[Content.from_text(text="It's 14°C and foggy in SF.")])
agent = Agent(
client=streaming_chat_client_stub(stream_fn),
name="weather_agent",
instructions="Answer weather questions.",
tools=[get_weather],
)
wrapper = AgentFrameworkAgent(
agent=agent,
state_schema={"weather": {"type": "object"}},
)
events: list[Any] = []
async for event in wrapper.run(
{
"thread_id": "thread-weather",
"run_id": "run-weather",
"messages": [{"role": "user", "content": "What's the weather in SF?"}],
"state": {"weather": {}},
}
):
events.append(event)
types = [e.type for e in events]
# The tool call must be visible in the stream.
assert "TOOL_CALL_START" in types, f"Missing TOOL_CALL_START in: {types}"
assert "TOOL_CALL_RESULT" in types, f"Missing TOOL_CALL_RESULT in: {types}"
# A STATE_SNAPSHOT must be emitted after the tool result.
tool_result_idx = types.index("TOOL_CALL_RESULT")
snapshot_indices_after_result = [i for i, t in enumerate(types) if t == "STATE_SNAPSHOT" and i > tool_result_idx]
assert snapshot_indices_after_result, (
f"Expected a STATE_SNAPSHOT after TOOL_CALL_RESULT (index {tool_result_idx}); got types: {types}"
)
# The tool's deterministic snapshot carries the actual fetched weather data.
final_snapshot = events[snapshot_indices_after_result[-1]].snapshot
assert final_snapshot["weather"] == {
"city": "SF",
"temperature": 14,
"conditions": "foggy",
}
# The LLM-visible tool result must carry the plain text, not the marker key.
tool_result_event = next(e for e in events if e.type == "TOOL_CALL_RESULT")
assert tool_result_event.content == "Weather in SF: 14°C foggy"
assert "__ag_ui_tool_result_state__" not in tool_result_event.content
@@ -18,7 +18,24 @@ def test_core_ag_ui_lazy_exports_include_only_stable_api() -> None:
assert hasattr(ag_ui, "AgentFrameworkAgent")
assert hasattr(ag_ui, "AGUIChatClient")
assert hasattr(ag_ui, "add_agent_framework_fastapi_endpoint")
assert hasattr(ag_ui, "state_update")
assert not hasattr(ag_ui, "WorkflowFactory")
assert not hasattr(ag_ui, "AGUIRequest")
assert not hasattr(ag_ui, "RunMetadata")
def test_agent_framework_ag_ui_exports_state_update() -> None:
"""Runtime package should export the ``state_update`` helper."""
from agent_framework_ag_ui import state_update
assert callable(state_update)
def test_core_ag_ui_lazy_exports_include_event_converter_and_http_service() -> None:
"""Core facade must expose AGUIEventConverter, AGUIHttpService, and __version__."""
from agent_framework import ag_ui
assert hasattr(ag_ui, "AGUIEventConverter")
assert hasattr(ag_ui, "AGUIHttpService")
assert hasattr(ag_ui, "__version__")
@@ -2,14 +2,20 @@
"""Tests for _run_common.py edge cases."""
from ag_ui.core import EventType
from agent_framework import Content
from agent_framework_ag_ui import state_update
from agent_framework_ag_ui._orchestration._predictive_state import PredictiveStateHandler
from agent_framework_ag_ui._run_common import (
FlowState,
_emit_mcp_tool_result,
_emit_tool_result,
_extract_resume_payload,
_extract_tool_result_state,
_normalize_resume_interrupts,
)
from agent_framework_ag_ui._state import TOOL_RESULT_STATE_KEY
class TestNormalizeResumeInterrupts:
@@ -120,3 +126,223 @@ class TestEmitToolResult:
assert "TEXT_MESSAGE_END" in event_types
assert flow.message_id is None
assert flow.accumulated_text == ""
class TestStateUpdateHelper:
"""Tests for the public ``state_update`` helper."""
def test_builds_text_content_with_state_marker(self):
"""state_update returns a text Content carrying state in additional_properties."""
c = state_update(text="done", state={"weather": {"temp": 14}})
assert c.type == "text"
assert c.text == "done"
assert c.additional_properties == {
TOOL_RESULT_STATE_KEY: {"weather": {"temp": 14}},
}
def test_empty_text_is_allowed(self):
"""State-only tools can omit the text argument."""
c = state_update(state={"steps": ["a", "b"]})
assert c.text == ""
assert c.additional_properties[TOOL_RESULT_STATE_KEY] == {"steps": ["a", "b"]}
def test_non_mapping_state_raises(self):
"""Passing a non-mapping value for state raises TypeError."""
import pytest
with pytest.raises(TypeError):
state_update(text="t", state=["not", "a", "mapping"]) # type: ignore[arg-type]
def test_state_is_copied_defensively(self):
"""Mutating the caller's dict after ``state_update`` must not mutate the content."""
caller_state = {"weather": {"temp": 14}}
c = state_update(text="ok", state=caller_state)
caller_state["weather"]["temp"] = 99
# The top-level dict was copied, so replacing the key in caller_state
# would not affect the Content, but nested dicts share references — document
# this by asserting only the top-level copy semantics.
assert TOOL_RESULT_STATE_KEY in c.additional_properties
inner = c.additional_properties[TOOL_RESULT_STATE_KEY]
assert inner is not caller_state
class TestExtractToolResultState:
"""Tests for ``_extract_tool_result_state``."""
def test_returns_none_for_plain_string_result(self):
content = Content.from_function_result(call_id="c1", result="plain")
assert _extract_tool_result_state(content) is None
def test_extracts_state_from_inner_item(self):
tool_return = state_update(text="hi", state={"k": 1})
content = Content.from_function_result(call_id="c1", result=[tool_return])
assert _extract_tool_result_state(content) == {"k": 1}
def test_extracts_state_from_outer_additional_properties(self):
"""Outer function_result content can also carry state (legacy/advanced use)."""
content = Content.from_function_result(
call_id="c1",
result="hi",
additional_properties={TOOL_RESULT_STATE_KEY: {"k": 1}},
)
assert _extract_tool_result_state(content) == {"k": 1}
def test_merges_multiple_items(self):
a = state_update(text="a", state={"k": 1, "shared": "from_a"})
b = state_update(text="b", state={"shared": "from_b", "extra": True})
content = Content.from_function_result(call_id="c1", result=[a, b])
merged = _extract_tool_result_state(content)
assert merged == {"k": 1, "shared": "from_b", "extra": True}
def test_ignores_non_dict_marker_value(self):
"""A garbled marker value must not break extraction (defensive guard)."""
bad = Content.from_text(
"hi",
additional_properties={TOOL_RESULT_STATE_KEY: "not-a-dict"},
)
content = Content.from_function_result(call_id="c1", result=[bad])
assert _extract_tool_result_state(content) is None
class TestEmitToolResultWithState:
"""Tests for the deterministic state emission in ``_emit_tool_result``."""
def test_emits_state_snapshot_after_tool_call_result(self):
"""Tool returning state_update produces a StateSnapshotEvent right after the result."""
tool_return = state_update(
text="Weather: 14°C",
state={"weather": {"temp": 14, "conditions": "foggy"}},
)
content = Content.from_function_result(call_id="call_1", result=[tool_return])
flow = FlowState()
events = _emit_tool_result(content, flow)
event_types = [e.type for e in events]
# Expect TOOL_CALL_END, TOOL_CALL_RESULT, STATE_SNAPSHOT in that order.
assert event_types[0] == EventType.TOOL_CALL_END
assert event_types[1] == EventType.TOOL_CALL_RESULT
state_idx = event_types.index(EventType.STATE_SNAPSHOT)
assert state_idx == 2
assert events[state_idx].snapshot == {"weather": {"temp": 14, "conditions": "foggy"}}
def test_updates_flow_current_state(self):
tool_return = state_update(text="", state={"a": 1})
content = Content.from_function_result(call_id="c1", result=[tool_return])
flow = FlowState(current_state={"existing": "value"})
_emit_tool_result(content, flow)
# Existing keys must survive (merge semantics), new keys must be added.
assert flow.current_state == {"existing": "value", "a": 1}
def test_merge_overrides_existing_key(self):
tool_return = state_update(text="", state={"existing": "new"})
content = Content.from_function_result(call_id="c1", result=[tool_return])
flow = FlowState(current_state={"existing": "old", "other": 1})
_emit_tool_result(content, flow)
assert flow.current_state == {"existing": "new", "other": 1}
def test_no_state_snapshot_when_result_has_no_state(self):
"""Plain tool results must not emit a StateSnapshotEvent."""
content = Content.from_function_result(call_id="c1", result="plain")
flow = FlowState()
events = _emit_tool_result(content, flow)
assert all(e.type != EventType.STATE_SNAPSHOT for e in events)
def test_tool_result_content_text_unchanged(self):
"""The text sent to the LLM must not leak the state marker."""
tool_return = state_update(text="Weather: 14°C", state={"weather": {"temp": 14}})
content = Content.from_function_result(call_id="c1", result=[tool_return])
flow = FlowState()
events = _emit_tool_result(content, flow)
result_events = [e for e in events if e.type == EventType.TOOL_CALL_RESULT]
assert len(result_events) == 1
assert result_events[0].content == "Weather: 14°C"
assert TOOL_RESULT_STATE_KEY not in result_events[0].content
def test_coexists_with_active_predictive_state_handler(self):
"""Both predictive and deterministic state produce a single coalesced snapshot.
Predictive state (``predict_state_config``) and deterministic state
(``state_update``) are two independent mechanisms. When both are active,
a single coalesced ``StateSnapshotEvent`` is emitted containing the
merged result of both contributions.
"""
flow = FlowState(current_state={"preexisting": "value"})
handler = PredictiveStateHandler(
predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}},
current_state=flow.current_state,
)
tool_return = state_update(text="Draft written", state={"draft_final": True})
content = Content.from_function_result(call_id="c1", result=[tool_return])
events = _emit_tool_result(content, flow, predictive_handler=handler)
# Exactly one coalesced snapshot must be emitted containing all merged keys.
snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT]
assert len(snapshots) == 1
assert snapshots[0].snapshot["draft_final"] is True
assert snapshots[0].snapshot["preexisting"] == "value"
assert flow.current_state["draft_final"] is True
assert flow.current_state["preexisting"] == "value"
def test_predictive_and_deterministic_emit_single_snapshot(self):
"""When both predictive_handler and state_update are active, only one snapshot is emitted."""
flow = FlowState(current_state={"existing": "yes"})
handler = PredictiveStateHandler(
predict_state_config={"draft": {"tool": "write_draft", "tool_argument": "body"}},
current_state=flow.current_state,
)
tool_return = state_update(text="ok", state={"new_key": 42})
content = Content.from_function_result(call_id="c1", result=[tool_return])
events = _emit_tool_result(content, flow, predictive_handler=handler)
snapshots = [e for e in events if e.type == EventType.STATE_SNAPSHOT]
assert len(snapshots) == 1, f"Expected 1 coalesced snapshot, got {len(snapshots)}"
assert snapshots[0].snapshot == {"existing": "yes", "new_key": 42}
class TestEmitMcpToolResultWithState:
"""MCP tool results should honour the same state_update marker.
MCP results come from an external MCP server rather than a locally
executed ``@tool`` function, so they do not flow through ``parse_result``
and ``content.items`` is typically empty. State is instead carried on the
outer content's ``additional_properties`` (e.g. by middleware that
inspects the MCP output and attaches a marker). ``_extract_tool_result_state``
supports both locations so this path remains usable.
"""
def test_mcp_tool_result_emits_state_snapshot_from_additional_properties(self):
content = Content.from_mcp_server_tool_result(
call_id="mcp_1",
output="server result",
additional_properties={TOOL_RESULT_STATE_KEY: {"mcp_ok": True}},
)
flow = FlowState()
events = _emit_mcp_tool_result(content, flow)
event_types = [e.type for e in events]
assert EventType.TOOL_CALL_END in event_types
assert EventType.TOOL_CALL_RESULT in event_types
assert EventType.STATE_SNAPSHOT in event_types
assert flow.current_state == {"mcp_ok": True}
def test_mcp_tool_result_without_state_emits_no_snapshot(self):
content = Content.from_mcp_server_tool_result(
call_id="mcp_1",
output="server result",
)
flow = FlowState()
events = _emit_mcp_tool_result(content, flow)
assert all(e.type != EventType.STATE_SNAPSHOT for e in events)
@@ -43,9 +43,34 @@ class CosmosCheckpointStorage:
``FileCheckpointStorage``, allowing full Python object fidelity for
complex workflow state while keeping the document structure human-readable.
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load
checkpoints from trusted sources. Loading a malicious checkpoint can execute
arbitrary code.
Security warning: checkpoints use pickle for non-JSON-native values. Loading
checkpoints from untrusted sources is unsafe and can execute arbitrary code
during deserialization. The built-in deserialization restrictions reduce risk,
but they do not make untrusted checkpoints safe to load. Extending
``allowed_checkpoint_types`` may further increase risk and should only be done
for trusted application types.
By default, checkpoint deserialization is restricted to a built-in set of safe
Python types (primitives, datetime, uuid, ...) and all ``agent_framework``
internal types. To allow additional application-specific types, pass them via
the ``allowed_checkpoint_types`` parameter using ``"module:qualname"`` format.
Example:
.. code-block:: python
from azure.identity.aio import DefaultAzureCredential
from agent_framework_azure_cosmos import CosmosCheckpointStorage
storage = CosmosCheckpointStorage(
endpoint="https://my-account.documents.azure.com:443/",
credential=DefaultAzureCredential(),
database_name="agent-db",
container_name="checkpoints",
allowed_checkpoint_types=[
"my_app.models:MyState",
],
)
The database and container are created automatically on first use
if they do not already exist. The container uses partition key
@@ -97,6 +122,7 @@ class CosmosCheckpointStorage:
container_client: ContainerProxy | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
allowed_checkpoint_types: list[str] | None = None,
) -> None:
"""Initialize the Azure Cosmos DB checkpoint storage.
@@ -129,10 +155,15 @@ class CosmosCheckpointStorage:
container_client: Pre-created Cosmos container client.
env_file_path: Path to environment file for loading settings.
env_file_encoding: Encoding of the environment file.
allowed_checkpoint_types: Additional types (beyond the built-in safe set
and framework types) that are permitted during checkpoint
deserialization. Each entry should be a ``"module:qualname"``
string (e.g., ``"my_app.models:MyState"``).
"""
self._cosmos_client: CosmosClient | None = cosmos_client
self._container_proxy: ContainerProxy | None = container_client
self._owns_client = False
self._allowed_types: frozenset[str] = frozenset(allowed_checkpoint_types or [])
if self._container_proxy is not None:
self.database_name: str = database_name or ""
@@ -401,8 +432,7 @@ class CosmosCheckpointStorage:
partition_key=PartitionKey(path="/workflow_name"),
)
@staticmethod
def _document_to_checkpoint(document: dict[str, Any]) -> WorkflowCheckpoint:
def _document_to_checkpoint(self, document: dict[str, Any]) -> WorkflowCheckpoint:
"""Convert a Cosmos DB document back to a WorkflowCheckpoint.
Strips Cosmos DB system properties (``_rid``, ``_self``, ``_etag``,
@@ -413,7 +443,7 @@ class CosmosCheckpointStorage:
cosmos_keys = {"id", "_rid", "_self", "_etag", "_attachments", "_ts"}
cleaned = {k: v for k, v in document.items() if k not in cosmos_keys}
decoded = decode_checkpoint_value(cleaned)
decoded = decode_checkpoint_value(cleaned, allowed_types=self._allowed_types)
return WorkflowCheckpoint.from_dict(decoded)
@staticmethod
@@ -6,6 +6,7 @@ import os
import uuid
from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
@@ -595,3 +596,142 @@ async def test_cosmos_checkpoint_storage_roundtrip_with_emulator() -> None:
finally:
with suppress(Exception):
await cosmos_client.delete_database(database_name)
# --- Tests for allowed_checkpoint_types ---
@dataclass
class _AppState:
"""Application-defined state type used to test allowed_checkpoint_types."""
label: str
count: int
_APP_STATE_TYPE_KEY = f"{_AppState.__module__}:{_AppState.__qualname__}"
def _make_checkpoint_with_state(state: dict[str, Any]) -> WorkflowCheckpoint:
"""Create a checkpoint with custom state for serialization tests."""
return WorkflowCheckpoint(
workflow_name="test-workflow",
graph_signature_hash="abc123",
timestamp="2025-01-01T00:00:00+00:00",
state=state,
iteration_count=1,
)
async def test_init_accepts_allowed_checkpoint_types(mock_container: MagicMock) -> None:
"""CosmosCheckpointStorage.__init__ accepts allowed_checkpoint_types."""
storage = CosmosCheckpointStorage(
container_client=mock_container,
allowed_checkpoint_types=["some.module:SomeType"],
)
assert storage is not None
async def test_load_allows_builtin_safe_types(mock_container: MagicMock) -> None:
"""Built-in safe types load without opt-in via allowed_checkpoint_types."""
from datetime import datetime, timezone
checkpoint = _make_checkpoint_with_state({
"ts": datetime(2025, 1, 1, tzinfo=timezone.utc),
"tags": {1, 2, 3},
})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(container_client=mock_container)
loaded = await storage.load(checkpoint.checkpoint_id)
assert loaded.state["ts"] == datetime(2025, 1, 1, tzinfo=timezone.utc)
assert loaded.state["tags"] == {1, 2, 3}
async def test_load_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
"""Application types are blocked when not listed in allowed_checkpoint_types."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(container_client=mock_container)
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
await storage.load(checkpoint.checkpoint_id)
async def test_load_allows_listed_app_type(mock_container: MagicMock) -> None:
"""Application types are allowed when listed in allowed_checkpoint_types."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=7)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(
container_client=mock_container,
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
)
loaded = await storage.load(checkpoint.checkpoint_id)
assert isinstance(loaded.state["data"], _AppState)
assert loaded.state["data"].label == "ok"
assert loaded.state["data"].count == 7
async def test_list_checkpoints_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
"""list_checkpoints skips documents with unlisted application types."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(container_client=mock_container)
results = await storage.list_checkpoints(workflow_name="test-workflow")
# The document is skipped (logged as warning) because the type is blocked
assert len(results) == 0
async def test_list_checkpoints_allows_listed_app_type(mock_container: MagicMock) -> None:
"""list_checkpoints decodes documents with listed application types."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="ok", count=3)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(
container_client=mock_container,
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
)
results = await storage.list_checkpoints(workflow_name="test-workflow")
assert len(results) == 1
assert isinstance(results[0].state["data"], _AppState)
async def test_get_latest_blocks_unlisted_app_type(mock_container: MagicMock) -> None:
"""get_latest raises when the checkpoint contains an unlisted application type."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="x", count=1)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(container_client=mock_container)
with pytest.raises(WorkflowCheckpointException, match="deserialization blocked"):
await storage.get_latest(workflow_name="test-workflow")
async def test_get_latest_allows_listed_app_type(mock_container: MagicMock) -> None:
"""get_latest decodes checkpoints with listed application types."""
checkpoint = _make_checkpoint_with_state({"data": _AppState(label="latest", count=9)})
doc = _checkpoint_to_cosmos_document(checkpoint)
mock_container.query_items.return_value = _to_async_iter([doc])
storage = CosmosCheckpointStorage(
container_client=mock_container,
allowed_checkpoint_types=[_APP_STATE_TYPE_KEY],
)
result = await storage.get_latest(workflow_name="test-workflow")
assert result is not None
assert isinstance(result.state["data"], _AppState)
assert result.state["data"].label == "latest"
@@ -486,8 +486,8 @@ YAML_KV_RE = re.compile(
)
# Validates skill names: lowercase letters, numbers, hyphens only;
# must not start or end with a hyphen.
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$")
# must not start or end with a hyphen, and must not contain consecutive hyphens.
VALID_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9]*-[a-z0-9])*[a-z0-9]*$")
# Default system prompt template for advertising available skills to the model.
# Use {skills} as the placeholder for the generated skills XML list.
@@ -1156,7 +1156,8 @@ def _validate_skill_metadata(
if len(name) > MAX_NAME_LENGTH or not VALID_NAME_RE.match(name):
return (
f"Skill from '{source}' has an invalid name '{name}': Must be {MAX_NAME_LENGTH} characters or fewer, "
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen."
"using only lowercase letters, numbers, and hyphens, and must not start or end with a hyphen "
"or contain consecutive hyphens."
)
if not description or not description.strip():
@@ -1241,6 +1242,17 @@ def _read_and_parse_skill_file(
return None
name, description = result
dir_name = Path(skill_dir_path).name
if name != dir_name:
logger.error(
"SKILL.md at '%s' has frontmatter name '%s' that does not match the directory name '%s'; skipping.",
skill_file,
name,
dir_name,
)
return None
return name, description, content
@@ -26,6 +26,28 @@ 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: list[str] = []
def append_to_user_agent(prefix: str) -> None:
"""Prepend a prefix to the agent framework user agent string.
This is useful for hosting layers that want to identify themselves in telemetry.
Duplicate prefixes are ignored.
Args:
prefix: The prefix to prepend (e.g. "foundry-hosting").
"""
if prefix and prefix not in _user_agent_prefixes:
_user_agent_prefixes.append(prefix)
def _get_user_agent() -> str:
"""Return the full user agent string including any prepended prefixes."""
if not _user_agent_prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(_user_agent_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 +79,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
@@ -2816,6 +2816,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
cleanup_hooks if cleanup_hooks is not None else []
)
self._cleanup_run: bool = False
self._stream_error: Exception | None = None
self._inner_stream: ResponseStream[Any, Any] | None = None
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
@@ -2948,8 +2949,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
await self._run_cleanup_hooks()
await self.get_final_response()
raise
except Exception:
await self._run_cleanup_hooks()
except Exception as exc:
self._stream_error = exc
try:
await self._run_cleanup_hooks()
finally:
self._stream_error = None
raise
if self._map_update is not None:
update = self._map_update(update) # type: ignore[assignment]
@@ -119,15 +119,11 @@ class WorkflowAgent(BaseAgent):
if not any(is_type_compatible(list[Message], input_type) for input_type in start_executor.input_types):
raise ValueError("Workflow's start executor cannot handle list[Message]")
resolved_context_providers = list(context_providers) if context_providers is not None else []
if not resolved_context_providers:
resolved_context_providers.append(InMemoryHistoryProvider())
super().__init__(
id=id,
name=name,
description=description,
context_providers=resolved_context_providers,
context_providers=context_providers,
**kwargs,
)
self._workflow: Workflow = workflow
@@ -261,6 +257,15 @@ class WorkflowAgent(BaseAgent):
An AgentResponse representing the workflow execution results.
"""
input_messages = normalize_messages_input(messages)
if (
not any(
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
)
and session is not None
):
self.context_providers.append(InMemoryHistoryProvider())
provider_session = session
if provider_session is None and self.context_providers:
provider_session = AgentSession()
@@ -332,6 +337,15 @@ class WorkflowAgent(BaseAgent):
AgentResponseUpdate objects representing the workflow execution progress.
"""
input_messages = normalize_messages_input(messages)
if (
not any(
provider.load_messages for provider in self.context_providers if isinstance(provider, HistoryProvider)
)
and session is not None
):
self.context_providers.append(InMemoryHistoryProvider())
provider_session = session
if provider_session is None and self.context_providers:
provider_session = AgentSession()
@@ -7,10 +7,13 @@ This module lazily re-exports objects from:
Supported classes and functions:
- AgentFrameworkAgent
- AgentFrameworkWorkflow
- AGUIChatClient
- AGUIEventConverter
- AGUIHttpService
- add_agent_framework_fastapi_endpoint
- state_update
- __version__
"""
import importlib
@@ -23,6 +26,10 @@ _IMPORTS = [
"AgentFrameworkWorkflow",
"add_agent_framework_fastapi_endpoint",
"AGUIChatClient",
"AGUIEventConverter",
"AGUIHttpService",
"state_update",
"__version__",
]
@@ -8,6 +8,7 @@ from agent_framework_ag_ui import (
AGUIHttpService,
__version__,
add_agent_framework_fastapi_endpoint,
state_update,
)
__all__ = [
@@ -18,4 +19,5 @@ __all__ = [
"AgentFrameworkWorkflow",
"__version__",
"add_agent_framework_fastapi_endpoint",
"state_update",
]
@@ -1323,6 +1323,12 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
from ._types import ChatResponse
try:
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
# Stream errored; skip get_final_response() to avoid firing
# result hooks such as after_run context providers on error
# paths. Capture the error on the span before returning.
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
return
response: ChatResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(attributes, response)
@@ -1579,6 +1585,12 @@ class AgentTelemetryLayer:
from ._types import AgentResponse
try:
if result_stream._stream_error is not None: # pyright: ignore[reportPrivateUsage]
# Stream errored; skip get_final_response() to avoid firing
# result hooks such as after_run context providers on error
# paths. Capture the error on the span before returning.
capture_exception(span=span, exception=result_stream._stream_error, timestamp=time_ns()) # pyright: ignore[reportPrivateUsage]
return
response: AgentResponse[Any] = await result_stream.get_final_response()
duration = duration_state.get("duration")
response_attributes = _get_response_attributes(
+3 -3
View File
@@ -34,14 +34,13 @@ all = [
"mcp>=1.24.0,<2",
"agent-framework-a2a",
"agent-framework-ag-ui",
"agent-framework-anthropic",
"agent-framework-azure-ai-search",
"agent-framework-azure-cosmos",
"agent-framework-anthropic",
"agent-framework-openai",
"agent-framework-claude",
"agent-framework-azurefunctions",
"agent-framework-bedrock",
"agent-framework-chatkit",
"agent-framework-claude",
"agent-framework-copilotstudio",
"agent-framework-declarative",
"agent-framework-devui",
@@ -52,6 +51,7 @@ all = [
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-ollama",
"agent-framework-openai",
"agent-framework-orchestrations",
"agent-framework-purview",
"agent-framework-redis",
@@ -296,6 +296,15 @@ class TestDiscoverAndLoadSkills:
skills = _discover_file_skills([str(tmp_path)])
assert len(skills) == 0
def test_skips_skill_with_name_directory_mismatch(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "wrong-dir-name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
skills = _discover_file_skills([str(tmp_path)])
assert len(skills) == 0
def test_deduplicates_skill_names(self, tmp_path: Path) -> None:
dir1 = tmp_path / "dir1"
dir2 = tmp_path / "dir2"
@@ -904,6 +913,11 @@ class TestSkill:
provider = SkillsProvider(skills=[invalid_skill])
assert len(provider._skills) == 0
def test_name_with_consecutive_hyphens_skipped(self) -> None:
invalid_skill = Skill(name="consecutive--hyphens", description="A skill.", content="Body")
provider = SkillsProvider(skills=[invalid_skill])
assert len(provider._skills) == 0
def test_name_too_long_skipped(self) -> None:
invalid_skill = Skill(name="a" * 65, description="A skill.", content="Body")
provider = SkillsProvider(skills=[invalid_skill])
@@ -1421,6 +1435,11 @@ class TestValidateSkillMetadata:
assert result is not None
assert "invalid name" in result
def test_name_with_consecutive_hyphens(self) -> None:
result = _validate_skill_metadata("consecutive--hyphens", "desc", "source")
assert result is not None
assert "invalid name" in result
def test_single_char_name(self) -> None:
assert _validate_skill_metadata("a", "desc", "source") is None
@@ -1526,6 +1545,15 @@ class TestReadAndParseSkillFile:
result = _read_and_parse_skill_file(str(skill_dir))
assert result is None
def test_name_directory_mismatch_returns_none(self, tmp_path: Path) -> None:
skill_dir = tmp_path / "wrong-dir-name"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"---\nname: actual-skill-name\ndescription: A skill.\n---\nBody.", encoding="utf-8"
)
result = _read_and_parse_skill_file(str(skill_dir))
assert result is None
# ---------------------------------------------------------------------------
# Tests: _create_resource_element
@@ -14,6 +14,7 @@ from agent_framework import (
AgentSession,
Content,
Executor,
HistoryProvider,
InMemoryHistoryProvider,
Message,
ResponseStream,
@@ -678,6 +679,110 @@ class TestWorkflowAgent:
assert agent.context_providers == [explicit_provider]
async def test_no_history_provider_injected_when_session_is_none(self) -> None:
"""Test that InMemoryHistoryProvider is NOT injected when session is None."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Session Agent")
await agent.run("hello")
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_no_history_provider_injected_when_session_is_none_streaming(self) -> None:
"""Test that InMemoryHistoryProvider is NOT injected when session is None (streaming)."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_session_stream_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Session Stream Agent")
async for _ in agent.run("hello", stream=True):
pass
assert not any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_no_injection_when_history_provider_with_load_messages_exists(self) -> None:
"""Test that no InMemoryHistoryProvider is injected when an existing HistoryProvider has load_messages=True."""
capturing_executor = ConversationHistoryCapturingExecutor(id="existing_provider_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
existing_provider = InMemoryHistoryProvider("custom", load_messages=True)
agent = WorkflowAgent(
workflow=workflow,
name="Existing Provider Agent",
context_providers=[existing_provider],
)
session = AgentSession()
await agent.run("hello", session=session)
# Should still have only the original provider
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
assert len(history_providers) == 1
assert history_providers[0] is existing_provider
async def test_injection_when_history_provider_with_load_messages_false(self) -> None:
"""Test that InMemoryHistoryProvider IS injected when existing HistoryProvider has load_messages=False."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_load_provider_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
audit_provider = InMemoryHistoryProvider("audit", load_messages=False)
agent = WorkflowAgent(
workflow=workflow,
name="Audit Provider Agent",
context_providers=[audit_provider],
)
session = AgentSession()
await agent.run("hello", session=session)
# Should have injected an additional InMemoryHistoryProvider with load_messages=True
history_providers = [p for p in agent.context_providers if isinstance(p, HistoryProvider)]
assert len(history_providers) == 2
loading_providers = [p for p in history_providers if p.load_messages]
assert len(loading_providers) == 1
assert isinstance(loading_providers[0], InMemoryHistoryProvider)
async def test_no_duplicate_injection_on_multiple_runs(self) -> None:
"""Test that calling run() multiple times does not keep adding InMemoryHistoryProvider."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Dup Agent")
session = AgentSession()
await agent.run("first", session=session)
await agent.run("second", session=session)
await agent.run("third", session=session)
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
assert len(history_providers) == 1
async def test_no_duplicate_injection_on_multiple_runs_streaming(self) -> None:
"""Test that calling run(stream=True) multiple times does not keep adding InMemoryHistoryProvider."""
capturing_executor = ConversationHistoryCapturingExecutor(id="no_dup_stream_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="No Dup Stream Agent")
session = AgentSession()
async for _ in agent.run("first", stream=True, session=session):
pass
async for _ in agent.run("second", stream=True, session=session):
pass
async for _ in agent.run("third", stream=True, session=session):
pass
history_providers = [p for p in agent.context_providers if isinstance(p, InMemoryHistoryProvider)]
assert len(history_providers) == 1
async def test_injection_with_session_in_streaming_mode(self) -> None:
"""Test that InMemoryHistoryProvider is injected when session is provided in streaming mode."""
capturing_executor = ConversationHistoryCapturingExecutor(id="stream_inject_test")
workflow = WorkflowBuilder(start_executor=capturing_executor).build()
agent = WorkflowAgent(workflow=workflow, name="Stream Inject Agent")
session = AgentSession()
async for _ in agent.run("hello", stream=True, session=session):
pass
assert any(isinstance(p, InMemoryHistoryProvider) for p in agent.context_providers)
async def test_checkpoint_storage_passed_to_workflow(self) -> None:
"""Test that checkpoint_storage parameter is passed through to the workflow."""
from agent_framework import InMemoryCheckpointStorage
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -51,7 +51,7 @@ export default tseslint.config([
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
+56 -4
View File
@@ -3,7 +3,7 @@
* Features: Entity selection, layout management, debug coordination
*/
import { useEffect, useCallback, useState } from "react";
import { useEffect, useCallback, useRef, useState } from "react";
import { AppHeader, DebugPanel, SettingsModal, DeploymentModal } from "@/components/layout";
import { GalleryView } from "@/components/features/gallery";
import { AgentView } from "@/components/features/agent";
@@ -15,17 +15,22 @@ import type {
AgentInfo,
WorkflowInfo,
ExtendedResponseStreamEvent,
ResponseTextDeltaEvent,
} from "@/types";
import { Button } from "./components/ui/button";
import { Input } from "./components/ui/input";
import { useDevUIStore } from "@/stores";
const DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS = 50;
export default function App() {
// Local state for auth handling
const [authRequired, setAuthRequired] = useState(false);
const [authToken, setAuthToken] = useState("");
const [isTestingToken, setIsTestingToken] = useState(false);
const [authError, setAuthError] = useState("");
const bufferedDebugTextRef = useRef<ResponseTextDeltaEvent | null>(null);
const lastBufferedDebugFlushAtRef = useRef(0);
// Entity state from Zustand
const agents = useDevUIStore((state) => state.agents);
@@ -303,16 +308,63 @@ export default function App() {
[selectEntity, updateAgent, updateWorkflow, addToast]
);
const flushBufferedDebugText = useCallback(() => {
const bufferedEvent = bufferedDebugTextRef.current;
if (!bufferedEvent) {
return;
}
bufferedDebugTextRef.current = null;
lastBufferedDebugFlushAtRef.current = performance.now();
addDebugEvent(bufferedEvent);
}, [addDebugEvent]);
// Handle debug events from active view
const handleDebugEvent = useCallback(
(event: ExtendedResponseStreamEvent | "clear") => {
if (event === "clear") {
bufferedDebugTextRef.current = null;
clearDebugEvents();
} else {
addDebugEvent(event);
return;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
const bufferedEvent = bufferedDebugTextRef.current;
const isSameOutput =
bufferedEvent !== null &&
bufferedEvent.item_id === event.item_id &&
bufferedEvent.output_index === event.output_index &&
bufferedEvent.content_index === event.content_index;
if (isSameOutput && bufferedEvent) {
bufferedDebugTextRef.current = {
...bufferedEvent,
delta: bufferedEvent.delta + event.delta,
sequence_number: event.sequence_number ?? bufferedEvent.sequence_number,
};
} else {
flushBufferedDebugText();
bufferedDebugTextRef.current = { ...event } as ResponseTextDeltaEvent;
}
if (
performance.now() - lastBufferedDebugFlushAtRef.current >=
DEBUG_TEXT_EVENT_FLUSH_INTERVAL_MS
) {
flushBufferedDebugText();
}
return;
}
flushBufferedDebugText();
addDebugEvent(event);
},
[addDebugEvent, clearDebugEvents]
[addDebugEvent, clearDebugEvents, flushBufferedDebugText]
);
// Show loading state while initializing
@@ -44,6 +44,8 @@ import { loadStreamingState } from "@/services/streaming-state";
type DebugEventHandler = (event: ExtendedResponseStreamEvent | "clear") => void;
const ASSISTANT_TEXT_RENDER_INTERVAL_MS = 50;
interface AgentViewProps {
selectedAgent: AgentInfo;
onDebugEvent: DebugEventHandler;
@@ -309,6 +311,71 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
} | null>(null);
const userJustSentMessage = useRef<boolean>(false);
const accumulatedTextRef = useRef<string>("");
const lastAssistantTextRenderAt = useRef(0);
const renderAssistantStreamingText = useCallback(
(
assistantMessageId: string,
status: "in_progress" | "completed" | "incomplete" = "in_progress",
force: boolean = false
) => {
const now = performance.now();
if (
!force &&
now - lastAssistantTextRenderAt.current < ASSISTANT_TEXT_RENDER_INTERVAL_MS
) {
return;
}
const currentItems = useDevUIStore.getState().chatItems;
let changed = false;
const nextItems = currentItems.map((item) => {
if (item.id !== assistantMessageId || item.type !== "message") {
return item;
}
const nextText = accumulatedTextRef.current;
const existingTextContent = item.content.find(
(content) => content.type === "text" || content.type === "output_text"
);
const currentText =
existingTextContent && "text" in existingTextContent
? existingTextContent.text
: "";
if (currentText === nextText && item.status === status) {
return item;
}
changed = true;
const existingNonTextContent = item.content.filter(
(content) => content.type !== "text" && content.type !== "output_text"
);
return {
...item,
content: nextText
? [
...existingNonTextContent,
{
type: "text",
text: nextText,
} as import("@/types/openai").MessageTextContent,
]
: existingNonTextContent,
status,
};
});
if (changed) {
lastAssistantTextRenderAt.current = now;
setChatItems(nextItems);
} else if (force) {
lastAssistantTextRenderAt.current = now;
}
},
[setChatItems]
);
// Auto-scroll to bottom when new items arrive
useEffect(() => {
@@ -382,6 +449,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
undefined, // No abort signal for resume
storedState.responseId // Pass response ID for resume
);
lastAssistantTextRenderAt.current = 0;
for await (const openAIEvent of streamGenerator) {
// Pass all events to debug panel
@@ -412,6 +480,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
: JSON.stringify(error)
: "Request failed";
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return;
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -434,6 +508,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
setPendingApprovals([
...useDevUIStore.getState().pendingApprovals,
{
@@ -458,6 +533,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorEvent = openAIEvent as ExtendedResponseStreamEvent & { message?: string };
const errorMessage = errorEvent.message || "An error occurred";
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return;
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -484,27 +565,13 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
openAIEvent.delta
) {
accumulatedTextRef.current += openAIEvent.delta;
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
}
: item
));
renderAssistantStreamingText(assistantMessage.id);
}
}
// Stream ended - mark as complete
const finalUsage = currentMessageUsage.current;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
@@ -721,11 +788,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
setIsStreaming(false);
setCurrentConversation(undefined);
accumulatedTextRef.current = "";
lastAssistantTextRenderAt.current = 0;
loadConversations();
// currentConversation is intentionally excluded - this effect should only run when agent changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAgent, onDebugEvent, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
}, [selectedAgent, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setLoadingConversations, setAvailableConversations, setCurrentConversation, setPendingApprovals, updateConversationUsage]);
// Removed old input handling functions - now handled by ChatMessageInput component
@@ -1118,6 +1186,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Clear text accumulator for new response
accumulatedTextRef.current = "";
lastAssistantTextRenderAt.current = 0;
// Create new AbortController for this request
const signal = createAbortSignal();
@@ -1167,6 +1236,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
}
// Update assistant message with error
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return; // Exit stream processing on failure
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1189,6 +1264,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Handle function approval request events
if (openAIEvent.type === "response.function_approval.requested") {
const approvalEvent = openAIEvent as import("@/types/openai").ResponseFunctionApprovalRequestedEvent;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
// Add to pending approvals (for popup)
setPendingApprovals([
@@ -1267,6 +1343,12 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
const errorMessage = errorEvent.message || "An error occurred";
// Update assistant message with error and stop streaming
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
setIsStreaming(false);
return; // Exit stream processing early on error
}
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
@@ -1290,6 +1372,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
if (openAIEvent.type === "response.output_item.added") {
const outputItemEvent = openAIEvent as import("@/types/openai").ResponseOutputItemAddedEvent;
const item = outputItemEvent.item;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
// Handle function calls as separate conversation items
if (item.type === "function_call") {
@@ -1363,28 +1446,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
openAIEvent.delta
) {
accumulatedTextRef.current += openAIEvent.delta;
// Update assistant message with accumulated content
// Preserve any existing non-text content (images, files, data)
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) => {
if (item.id === assistantMessage.id && item.type === "message") {
// Keep existing non-text content, update text content
const existingNonTextContent = item.content.filter(c => c.type !== "text");
return {
...item,
content: [
...existingNonTextContent,
{
type: "text",
text: accumulatedTextRef.current,
} as import("@/types/openai").MessageTextContent,
],
status: "in_progress" as const,
};
}
return item;
}));
renderAssistantStreamingText(assistantMessage.id);
}
// Handle completion/error by detecting when streaming stops
@@ -1394,6 +1456,7 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
// Stream ended - mark as complete
// Usage is provided via response.completed event (OpenAI standard)
const finalUsage = currentMessageUsage.current;
renderAssistantStreamingText(assistantMessage.id, "in_progress", true);
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
@@ -1419,45 +1482,42 @@ export function AgentView({ selectedAgent, onDebugEvent }: AgentViewProps) {
if (isAbortError(error)) {
// User cancelled - mark as cancelled for UI feedback
setWasCancelled(true);
// Mark the message as completed with what we have
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
status: accumulatedTextRef.current ? "completed" as const : "incomplete" as const,
// Keep whatever text we have accumulated
content: item.content,
}
: item
));
renderAssistantStreamingText(
assistantMessage.id,
accumulatedTextRef.current ? "completed" : "incomplete",
true
);
} else {
// Other errors - show error message
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
if (accumulatedTextRef.current) {
renderAssistantStreamingText(assistantMessage.id, "incomplete", true);
} else {
const currentItems = useDevUIStore.getState().chatItems;
setChatItems(currentItems.map((item) =>
item.id === assistantMessage.id && item.type === "message"
? {
...item,
content: [
{
type: "text",
text: `Error: ${
error instanceof Error
? error.message
: "Failed to get response"
}`,
} as import("@/types/openai").MessageTextContent,
],
status: "incomplete" as const,
}
: item
));
}
}
setIsStreaming(false);
resetCancelling();
}
},
[selectedAgent, currentConversation, onDebugEvent, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
[selectedAgent, currentConversation, onDebugEvent, renderAssistantStreamingText, setChatItems, setIsStreaming, setCurrentConversation, setAvailableConversations, setPendingApprovals, updateConversationUsage, createAbortSignal, resetCancelling]
);
// Handle non-streaming message sending
@@ -16,9 +16,10 @@ import type {
import type { AgentFrameworkRequest } from "@/types/agent-framework";
import type { ExtendedResponseStreamEvent } from "@/types/openai";
import {
applyStreamingEventToState,
createStreamingState,
loadStreamingState,
updateStreamingState,
markStreamingCompleted,
saveStreamingState,
clearStreamingState,
} from "./streaming-state";
import { isAbortError } from "@/hooks";
@@ -72,6 +73,7 @@ const DEFAULT_API_BASE_URL =
// Retry configuration for streaming
const RETRY_INTERVAL_MS = 1000; // Base retry interval (will use exponential backoff)
const MAX_RETRY_ATTEMPTS = 10; // Max 10 retries (~30 seconds with exponential backoff)
const STREAMING_STATE_SAVE_INTERVAL_MS = 250;
// Get backend URL from localStorage or default
function getBackendUrl(): string {
@@ -223,7 +225,7 @@ class ApiClient {
chat_client_type: entity.chat_client_type,
context_provider: entity.context_provider,
middleware: entity.middleware,
};
} as AgentInfo;
} else {
// Workflow - prefer executors field, fall back to tools for backward compatibility
const executorList = entity.executors || entity.tools || [];
@@ -263,7 +265,7 @@ class ApiClient {
input_type_name: entity.input_type_name || "Input",
start_executor_id: startExecutorId,
tools: [],
};
} as WorkflowInfo;
}
});
@@ -484,31 +486,65 @@ class ApiClient {
let hasYieldedAnyEvent = false;
let currentResponseId: string | undefined = resumeResponseId;
let lastMessageId: string | undefined = undefined;
let lastStreamingStateSaveAt = 0;
let storedState = conversationId ? loadStreamingState(conversationId) : null;
let streamingState = storedState ? { ...storedState } : null;
const persistStreamingState = (force: boolean = false): void => {
if (!conversationId || !streamingState) {
return;
}
const now = Date.now();
if (!force && now - lastStreamingStateSaveAt < STREAMING_STATE_SAVE_INTERVAL_MS) {
return;
}
lastStreamingStateSaveAt = now;
saveStreamingState({
...streamingState,
timestamp: now,
});
};
const recordStreamingEvent = (event: ExtendedResponseStreamEvent): void => {
if (!conversationId || !currentResponseId) {
return;
}
streamingState = applyStreamingEventToState(
streamingState ?? createStreamingState({
conversationId,
responseId: currentResponseId,
lastMessageId,
lastSequenceNumber,
accumulatedText: storedState?.accumulatedText,
}),
event,
currentResponseId,
lastMessageId
);
const isTextDelta =
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0;
persistStreamingState(!isTextDelta);
};
// Try to resume from stored state if conversation ID is provided
if (conversationId) {
const storedState = loadStreamingState(conversationId);
if (storedState) {
// Use stored response ID if no explicit one provided
if (!resumeResponseId) {
currentResponseId = storedState.responseId;
}
lastSequenceNumber = storedState.lastSequenceNumber;
lastMessageId = storedState.lastMessageId;
// Replay stored events only if we're not explicitly resuming
// (explicit resume means the caller already has the events)
if (!resumeResponseId) {
for (const event of storedState.events) {
hasYieldedAnyEvent = true;
yield event;
}
} else {
// Mark that we've already seen events up to this sequence number
hasYieldedAnyEvent = storedState.events.length > 0;
}
if (storedState) {
// Use stored response ID if no explicit one provided
if (!resumeResponseId) {
currentResponseId = storedState.responseId;
}
lastSequenceNumber = storedState.lastSequenceNumber;
lastMessageId = storedState.lastMessageId;
hasYieldedAnyEvent =
storedState.lastSequenceNumber >= 0 ||
Boolean(storedState.accumulatedText);
}
while (retryCount <= MAX_RETRY_ATTEMPTS) {
@@ -621,7 +657,8 @@ class ApiClient {
if (done) {
// Stream completed successfully
if (conversationId) {
markStreamingCompleted(conversationId);
clearStreamingState(conversationId);
streamingState = null;
}
return;
}
@@ -640,7 +677,8 @@ class ApiClient {
// Handle [DONE] signal
if (dataStr === "[DONE]") {
if (conversationId) {
markStreamingCompleted(conversationId);
clearStreamingState(conversationId);
streamingState = null;
}
return;
}
@@ -676,6 +714,9 @@ class ApiClient {
if (conversationId) {
clearStreamingState(conversationId);
}
storedState = null;
streamingState = null;
lastStreamingStateSaveAt = 0;
yield {
type: "error",
message: "Connection lost - previous response failed. Starting new response.",
@@ -684,9 +725,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Save new event to storage
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -698,9 +737,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Save event to storage before yielding
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -709,9 +746,7 @@ class ApiClient {
hasYieldedAnyEvent = true;
// Still save to storage if we have conversation context
if (conversationId && currentResponseId) {
updateStreamingState(conversationId, openAIEvent, currentResponseId, lastMessageId);
}
recordStreamingEvent(openAIEvent);
yield openAIEvent;
}
@@ -730,7 +765,8 @@ class ApiClient {
// Don't retry on abort
if (isAbortError(error)) {
if (conversationId) {
markStreamingCompleted(conversationId); // Clean up state
clearStreamingState(conversationId);
streamingState = null;
}
throw error; // Re-throw abort error without retrying
}
@@ -3,7 +3,6 @@
*
* Manages browser storage of streaming response state to enable:
* - Resume interrupted streams after page refresh
* - Replay cached events before fetching new ones
* - Graceful recovery from network disconnections
*/
@@ -14,7 +13,6 @@ export interface StreamingState {
responseId: string;
lastMessageId?: string;
lastSequenceNumber: number;
events: ExtendedResponseStreamEvent[];
timestamp: number; // When this state was last updated
completed: boolean; // Whether the stream completed successfully
accumulatedText?: string; // Accumulated text content for quick restoration
@@ -23,6 +21,14 @@ export interface StreamingState {
const STORAGE_KEY_PREFIX = "devui_streaming_state_";
const STATE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
interface CreateStreamingStateOptions {
conversationId: string;
responseId: string;
lastMessageId?: string;
lastSequenceNumber?: number;
accumulatedText?: string;
}
/**
* Storage key for a specific conversation
*/
@@ -31,16 +37,81 @@ function getStorageKey(conversationId: string): string {
}
/**
* Extract accumulated text from events (for quick restoration)
* Read raw streaming state from storage, including completed entries.
*/
function extractAccumulatedText(events: ExtendedResponseStreamEvent[]): string {
let text = "";
for (const event of events) {
if (event.type === "response.output_text.delta" && "delta" in event) {
text += event.delta;
}
function readStreamingState(conversationId: string): StreamingState | null {
const key = getStorageKey(conversationId);
const data = localStorage.getItem(key);
if (!data) {
return null;
}
return text;
const state: StreamingState = JSON.parse(data);
// Check if state has expired
const age = Date.now() - state.timestamp;
if (age > STATE_EXPIRY_MS) {
clearStreamingState(conversationId);
return null;
}
return state;
}
/**
* Create an initial streaming state snapshot.
*/
export function createStreamingState({
conversationId,
responseId,
lastMessageId,
lastSequenceNumber = -1,
accumulatedText,
}: CreateStreamingStateOptions): StreamingState {
return {
conversationId,
responseId,
lastMessageId,
lastSequenceNumber,
timestamp: Date.now(),
completed: false,
accumulatedText,
};
}
/**
* Apply an incoming stream event to an in-memory streaming state snapshot.
*/
export function applyStreamingEventToState(
state: StreamingState,
event: ExtendedResponseStreamEvent,
responseId: string,
lastMessageId?: string
): StreamingState {
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
const nextState: StreamingState = {
...state,
responseId,
lastMessageId,
timestamp: Date.now(),
completed: event.type === "response.completed" || event.type === "response.failed",
};
if (sequenceNumber !== undefined) {
nextState.lastSequenceNumber = sequenceNumber;
}
if (
event.type === "response.output_text.delta" &&
"delta" in event &&
typeof event.delta === "string" &&
event.delta.length > 0
) {
nextState.accumulatedText = `${state.accumulatedText ?? ""}${event.delta}`;
}
return nextState;
}
/**
@@ -71,19 +142,8 @@ export function saveStreamingState(state: StreamingState): void {
*/
export function loadStreamingState(conversationId: string): StreamingState | null {
try {
const key = getStorageKey(conversationId);
const data = localStorage.getItem(key);
if (!data) {
return null;
}
const state: StreamingState = JSON.parse(data);
// Check if state has expired
const age = Date.now() - state.timestamp;
if (age > STATE_EXPIRY_MS) {
clearStreamingState(conversationId);
const state = readStreamingState(conversationId);
if (!state) {
return null;
}
@@ -99,54 +159,6 @@ export function loadStreamingState(conversationId: string): StreamingState | nul
}
}
/**
* Update streaming state with a new event
*/
export function updateStreamingState(
conversationId: string,
event: ExtendedResponseStreamEvent,
responseId: string,
lastMessageId?: string
): void {
try {
const existing = loadStreamingState(conversationId);
const sequenceNumber = "sequence_number" in event ? event.sequence_number : undefined;
const newEvents = existing ? [...existing.events, event] : [event];
const state: StreamingState = {
conversationId,
responseId,
lastMessageId,
lastSequenceNumber: sequenceNumber ?? (existing?.lastSequenceNumber ?? -1),
events: newEvents,
timestamp: Date.now(),
completed: event.type === "response.completed" || event.type === "response.failed",
accumulatedText: extractAccumulatedText(newEvents),
};
saveStreamingState(state);
} catch (error) {
console.error("Failed to update streaming state:", error);
}
}
/**
* Mark streaming state as completed
*/
export function markStreamingCompleted(conversationId: string): void {
try {
const existing = loadStreamingState(conversationId);
if (existing) {
existing.completed = true;
existing.timestamp = Date.now();
saveStreamingState(existing);
}
} catch (error) {
console.error("Failed to mark streaming as completed:", error);
}
}
/**
* Clear streaming state for a conversation
*/
@@ -0,0 +1,750 @@
# Copyright (c) Microsoft. All rights reserved.
"""Browser-based regression test for DevUI streaming memory growth."""
from __future__ import annotations
import asyncio
import contextlib
import http.client
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import AsyncIterable, Awaitable, Generator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import pytest
import uvicorn
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
AgentSession,
BaseAgent,
Content,
Message,
ResponseStream,
)
from websockets.asyncio.client import connect as websocket_connect
from agent_framework_devui import DevServer
_BROWSER_COMMANDS = (
"chrome",
"chrome.exe",
"google-chrome",
"google-chrome-stable",
"chromium",
"chromium-browser",
"microsoft-edge",
"msedge",
"msedge.exe",
)
_BROWSER_ENV_VARS = ("DEVUI_TEST_BROWSER", "CHROME_BIN", "BROWSER_BIN")
_WINDOWS_PROCESS_QUERY = """
$rows = @(
Get-CimInstance Win32_Process | ForEach-Object {
if (-not $_.CommandLine) {
return
}
try {
$process = Get-Process -Id $_.ProcessId -ErrorAction Stop
[PSCustomObject]@{
pid = [int]$_.ProcessId
parent_pid = [int]$_.ParentProcessId
rss_kb = [int][Math]::Round($process.WorkingSet64 / 1KB)
command = [string]$_.CommandLine
}
}
catch {
}
}
)
$rows | ConvertTo-Json -Compress
""".strip()
_STREAM_CHUNK_COUNT = 12_000
_STREAM_CHUNK_SIZE = 128
_POST_SEND_DELAY_S = 1.0
_SAMPLE_INTERVAL_S = 0.5
_SAMPLE_WINDOW_S = 12.0
_MAX_RENDERER_GROWTH_MB = 500.0
@dataclass(frozen=True)
class _BrowserProcessRow:
pid: int
parent_pid: int
rss_kb: int
command: str
class MemoryStressAgent(BaseAgent):
"""Agent that emits many small streaming chunks."""
def __init__(self, *, chunk_count: int, chunk_size: int, delay_ms: float, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._chunk_count = chunk_count
self._chunk_size = max(chunk_size, 24)
self._delay_s = max(delay_ms, 0.0) / 1000.0
def run(
self,
messages: str | Message | list[str] | list[Message] | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse] | ResponseStream[AgentResponseUpdate, AgentResponse]:
del messages, session, kwargs
if stream:
return self._run_stream()
return self._run()
async def _run(self) -> AgentResponse:
text = "".join(self._make_chunk(index) for index in range(self._chunk_count))
return AgentResponse(messages=[Message("assistant", [Content.from_text(text=text)])])
def _run_stream(self) -> ResponseStream[AgentResponseUpdate, AgentResponse]:
async def _iter() -> AsyncIterable[AgentResponseUpdate]:
for index in range(self._chunk_count):
yield AgentResponseUpdate(
contents=[Content.from_text(text=self._make_chunk(index))],
role="assistant",
)
if self._delay_s:
await asyncio.sleep(self._delay_s)
return ResponseStream(_iter(), finalizer=AgentResponse.from_updates)
def _make_chunk(self, index: int) -> str:
prefix = f"[{index:06d}] "
payload_size = max(self._chunk_size - len(prefix), 1)
payload = ("x" * (payload_size - 1)) + ("\n" if index % 8 == 7 else " ")
return prefix + payload
class _CDPClient:
"""Minimal Chrome DevTools Protocol client for a single attached page."""
def __init__(self, websocket: Any) -> None:
self._websocket = websocket
self._next_id = 0
async def send(
self,
method: str,
params: dict[str, Any] | None = None,
*,
session_id: str | None = None,
) -> dict[str, Any]:
self._next_id += 1
command_id = self._next_id
payload: dict[str, Any] = {"id": command_id, "method": method}
if params is not None:
payload["params"] = params
if session_id is not None:
payload["sessionId"] = session_id
await self._websocket.send(json.dumps(payload))
while True:
raw_message = await self._websocket.recv()
if isinstance(raw_message, bytes):
raw_message = raw_message.decode("utf-8")
message = json.loads(raw_message)
if message.get("id") != command_id:
continue
error = message.get("error")
if isinstance(error, dict):
raise RuntimeError(f"CDP command {method} failed: {error}")
result = message.get("result")
return result if isinstance(result, dict) else {}
async def evaluate(self, expression: str, *, session_id: str) -> Any:
result = await self.send(
"Runtime.evaluate",
{
"expression": expression,
"awaitPromise": True,
"returnByValue": True,
},
session_id=session_id,
)
remote_result = result.get("result")
if isinstance(remote_result, dict):
return remote_result.get("value")
return None
def _get_browser_candidates() -> tuple[Path, ...]:
if sys.platform == "darwin":
return (
Path("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"),
Path("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
Path("/Applications/Chromium.app/Contents/MacOS/Chromium"),
)
if sys.platform == "win32":
windows_bases: list[Path] = []
for env_var in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"):
raw_value = os.environ.get(env_var)
if raw_value:
windows_bases.append(Path(raw_value))
return tuple(
dict.fromkeys(
[base / "Microsoft/Edge/Application/msedge.exe" for base in windows_bases]
+ [base / "Google/Chrome/Application/chrome.exe" for base in windows_bases]
+ [base / "Chromium/Application/chrome.exe" for base in windows_bases]
)
)
return (
Path("/usr/bin/google-chrome"),
Path("/usr/bin/google-chrome-stable"),
Path("/usr/bin/chromium"),
Path("/usr/bin/chromium-browser"),
Path("/usr/bin/microsoft-edge"),
Path("/opt/google/chrome/chrome"),
Path("/opt/microsoft/msedge/msedge"),
Path("/snap/bin/chromium"),
)
def _find_browser_executable() -> Path | None:
for env_var in _BROWSER_ENV_VARS:
configured_path = os.environ.get(env_var)
if not configured_path:
continue
candidate = Path(configured_path).expanduser()
if candidate.exists():
return candidate
for candidate in _get_browser_candidates():
if candidate.exists():
return candidate
for command in _BROWSER_COMMANDS:
resolved = shutil.which(command)
if resolved is not None:
return Path(resolved)
return None
def _find_available_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
sock.listen(1)
return int(sock.getsockname()[1])
def _get_json_response(*, host: str, port: int, path: str) -> dict[str, Any]:
connection = http.client.HTTPConnection(host, port, timeout=5)
try:
connection.request("GET", path)
response = connection.getresponse()
if response.status != 200:
raise RuntimeError(f"Request to {path} failed with status {response.status}")
payload = response.read().decode("utf-8")
finally:
connection.close()
data = json.loads(payload)
if isinstance(data, dict):
return data
raise RuntimeError(f"Expected JSON object from {path}, got: {type(data).__name__}")
async def _get_devtools_websocket_url(port: int) -> str:
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
with contextlib.suppress(Exception):
version_data = _get_json_response(host="127.0.0.1", port=port, path="/json/version")
websocket_url = version_data.get("webSocketDebuggerUrl")
if isinstance(websocket_url, str) and websocket_url:
return websocket_url
await asyncio.sleep(0.1)
raise RuntimeError(f"Timed out waiting for DevTools on port {port}")
def _wait_for_server_details(server_instance: uvicorn.Server) -> tuple[int, str]:
deadline = time.monotonic() + 10.0
actual_port: int | None = None
while time.monotonic() < deadline:
if hasattr(server_instance, "servers") and server_instance.servers:
for uvicorn_server in server_instance.servers:
sockets = getattr(uvicorn_server, "sockets", None)
if not sockets:
continue
actual_port = int(sockets[0].getsockname()[1])
break
if actual_port is not None:
with contextlib.suppress(Exception):
health = _get_json_response(host="127.0.0.1", port=actual_port, path="/health")
if health.get("status") == "healthy":
entities = _get_json_response(host="127.0.0.1", port=actual_port, path="/v1/entities")
entity_list = entities.get("entities")
if isinstance(entity_list, list) and entity_list:
entity = entity_list[0]
if isinstance(entity, dict) and isinstance(entity.get("id"), str):
return actual_port, entity["id"]
time.sleep(0.1)
raise RuntimeError("Timed out waiting for DevUI server startup")
def _parse_posix_process_rows(output: str) -> list[_BrowserProcessRow]:
rows: list[_BrowserProcessRow] = []
for line in output.splitlines():
parts = line.strip().split(None, 3)
if len(parts) != 4:
continue
pid_text, parent_pid_text, rss_text, command = parts
with contextlib.suppress(ValueError):
rows.append(
_BrowserProcessRow(
pid=int(pid_text),
parent_pid=int(parent_pid_text),
rss_kb=int(rss_text),
command=command,
)
)
return rows
def _parse_windows_process_rows(output: str) -> list[_BrowserProcessRow]:
text = output.strip()
if not text:
return []
payload = json.loads(text)
items = payload if isinstance(payload, list) else [payload]
rows: list[_BrowserProcessRow] = []
for item in items:
if not isinstance(item, dict):
continue
pid = item.get("pid")
parent_pid = item.get("parent_pid")
rss_kb = item.get("rss_kb")
command = item.get("command")
if not all(isinstance(value, int) for value in (pid, parent_pid, rss_kb)):
continue
if not isinstance(command, str):
continue
rows.append(
_BrowserProcessRow(
pid=pid,
parent_pid=parent_pid,
rss_kb=rss_kb,
command=command,
)
)
return rows
def _read_process_rows() -> list[_BrowserProcessRow]:
if sys.platform == "win32":
result = subprocess.run(
["powershell", "-NoProfile", "-Command", _WINDOWS_PROCESS_QUERY],
capture_output=True,
text=True,
check=True,
encoding="utf-8",
)
return _parse_windows_process_rows(result.stdout)
result = subprocess.run(
["ps", "-axo", "pid=,ppid=,rss=,command="],
capture_output=True,
text=True,
check=True,
)
return _parse_posix_process_rows(result.stdout)
def _collect_process_tree(root_pids: set[int], process_rows: list[_BrowserProcessRow]) -> list[_BrowserProcessRow]:
process_by_pid = {row.pid: row for row in process_rows}
child_pids_by_parent: dict[int, list[int]] = {}
for row in process_rows:
child_pids_by_parent.setdefault(row.parent_pid, []).append(row.pid)
collected_rows: list[_BrowserProcessRow] = []
seen_pids: set[int] = set()
pending_pids = list(root_pids)
while pending_pids:
pid = pending_pids.pop()
if pid in seen_pids:
continue
seen_pids.add(pid)
process_row = process_by_pid.get(pid)
if process_row is None:
continue
collected_rows.append(process_row)
pending_pids.extend(child_pids_by_parent.get(pid, []))
return collected_rows
def _collect_browser_process_rows(root_pid: int, profile_dir: str) -> list[_BrowserProcessRow]:
process_rows = _read_process_rows()
normalized_profile_dir = profile_dir.casefold()
matched_root_pids = {row.pid for row in process_rows if normalized_profile_dir in row.command.casefold()}
matched_root_pids.add(root_pid)
return _collect_process_tree(matched_root_pids, process_rows)
def _sample_peak_renderer_rss_mb(root_pid: int, profile_dir: str) -> float:
renderer_rss_kb = [
row.rss_kb
for row in _collect_browser_process_rows(root_pid, profile_dir)
if "--type=renderer" in row.command.casefold()
]
return round((max(renderer_rss_kb, default=0)) / 1024, 2)
def _terminate_browser_processes(root_pid: int, profile_dir: str) -> None:
browser_rows = _collect_browser_process_rows(root_pid, profile_dir)
browser_pids = sorted({row.pid for row in browser_rows} | {root_pid}, reverse=True)
if sys.platform == "win32":
for pid in browser_pids:
with contextlib.suppress(subprocess.CalledProcessError):
subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
return
for pid in browser_pids:
with contextlib.suppress(ProcessLookupError):
os.kill(pid, signal.SIGTERM)
def _launch_browser_process(*, browser_path: Path, debug_port: int, profile_dir: str) -> subprocess.Popen[str]:
return subprocess.Popen(
[
str(browser_path),
"--headless=new",
f"--remote-debugging-port={debug_port}",
"--remote-debugging-address=127.0.0.1",
f"--user-data-dir={profile_dir}",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-sync",
"--disable-renderer-backgrounding",
"--hide-scrollbars",
"--mute-audio",
"--enable-precise-memory-info",
"--no-sandbox",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
def _shutdown_browser_process(browser_process: subprocess.Popen[str], *, profile_dir: str) -> None:
with contextlib.suppress(Exception):
browser_process.terminate()
browser_process.wait(timeout=5)
_terminate_browser_processes(browser_process.pid, profile_dir)
def test_parse_posix_process_rows() -> None:
output = """
101 1 2048 /usr/bin/google-chrome --user-data-dir=/tmp/devui-memory
202 101 4096 /usr/bin/google-chrome --type=renderer --lang=en-US
""".strip()
assert _parse_posix_process_rows(output) == [
_BrowserProcessRow(
pid=101,
parent_pid=1,
rss_kb=2048,
command="/usr/bin/google-chrome --user-data-dir=/tmp/devui-memory",
),
_BrowserProcessRow(
pid=202,
parent_pid=101,
rss_kb=4096,
command="/usr/bin/google-chrome --type=renderer --lang=en-US",
),
]
def test_parse_windows_process_rows() -> None:
output = json.dumps([
{
"pid": 301,
"parent_pid": 1,
"rss_kb": 2048,
"command": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
},
{
"pid": 302,
"parent_pid": 301,
"rss_kb": 6144,
"command": r"C:\Program Files\Google\Chrome\Application\chrome.exe --type=renderer",
},
])
assert _parse_windows_process_rows(output) == [
_BrowserProcessRow(
pid=301,
parent_pid=1,
rss_kb=2048,
command=r"C:\Program Files\Google\Chrome\Application\chrome.exe",
),
_BrowserProcessRow(
pid=302,
parent_pid=301,
rss_kb=6144,
command=r"C:\Program Files\Google\Chrome\Application\chrome.exe --type=renderer",
),
]
def test_sample_peak_renderer_rss_mb_uses_browser_process_tree(
monkeypatch: pytest.MonkeyPatch,
) -> None:
profile_dir = "/tmp/devui-memory-browser"
process_rows = [
_BrowserProcessRow(
pid=101,
parent_pid=1,
rss_kb=1024,
command="/usr/bin/google-chrome",
),
_BrowserProcessRow(
pid=102,
parent_pid=101,
rss_kb=4096,
command="/usr/bin/google-chrome --type=renderer",
),
_BrowserProcessRow(
pid=201,
parent_pid=1,
rss_kb=2048,
command=f"/usr/bin/google-chrome --user-data-dir={profile_dir}",
),
_BrowserProcessRow(
pid=202,
parent_pid=201,
rss_kb=8192,
command="/usr/bin/google-chrome --type=renderer",
),
_BrowserProcessRow(
pid=999,
parent_pid=1,
rss_kb=32768,
command="/usr/bin/google-chrome --type=renderer",
),
]
monkeypatch.setattr(sys.modules[__name__], "_read_process_rows", lambda: process_rows)
assert _sample_peak_renderer_rss_mb(101, profile_dir) == 8.0
@pytest.fixture
def memory_regression_server() -> Generator[tuple[str, str]]:
"""Start DevUI with a synthetic streaming agent and yield the base URL plus entity ID."""
server = DevServer(host="127.0.0.1", port=0)
server.register_entities([
MemoryStressAgent(
id="memory-stream-agent",
name="MemoryStreamAgent",
description="Streams many small chunks for UI memory profiling.",
chunk_count=_STREAM_CHUNK_COUNT,
chunk_size=_STREAM_CHUNK_SIZE,
delay_ms=1.0,
)
])
app = server.get_app()
server_config = uvicorn.Config(
app=app,
host="127.0.0.1",
port=0,
log_level="error",
ws="none",
)
server_instance = uvicorn.Server(server_config)
def run_server() -> None:
asyncio.run(server_instance.serve())
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
actual_port, entity_id = _wait_for_server_details(server_instance)
yield f"http://127.0.0.1:{actual_port}", entity_id
with contextlib.suppress(Exception):
server_instance.should_exit = True
server_thread.join(timeout=5)
async def _wait_for_expression(
client: _CDPClient,
*,
session_id: str,
expression: str,
timeout_s: float,
) -> Any:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
value = await client.evaluate(expression, session_id=session_id)
if value:
return value
await asyncio.sleep(0.1)
raise AssertionError(f"Timed out waiting for expression: {expression}")
async def test_devui_streaming_renderer_memory_is_bounded(
memory_regression_server: tuple[str, str],
) -> None:
"""Fail when frontend renderer memory grows unbounded during streaming."""
browser_path = _find_browser_executable()
if browser_path is None:
pytest.skip("No Chromium-based browser found for DevUI memory regression test")
base_url, entity_id = memory_regression_server
debug_port = _find_available_port()
with tempfile.TemporaryDirectory(prefix="devui-memory-browser-") as profile_dir:
browser_process = _launch_browser_process(
browser_path=browser_path,
debug_port=debug_port,
profile_dir=profile_dir,
)
try:
websocket_url = await _get_devtools_websocket_url(debug_port)
async with websocket_connect(websocket_url, max_size=None) as websocket:
client = _CDPClient(websocket)
target = await client.send("Target.createTarget", {"url": "about:blank"})
target_id = target["targetId"]
attached = await client.send(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = attached["sessionId"]
await client.send("Page.enable", session_id=session_id)
await client.send("Runtime.enable", session_id=session_id)
await client.send(
"Page.navigate",
{"url": f"{base_url}/?entity_id={entity_id}"},
session_id=session_id,
)
await _wait_for_expression(
client,
session_id=session_id,
expression=(
"Boolean("
"document.querySelector('textarea') && "
"document.querySelector('button[aria-label=\"Send message\"]')"
")"
),
timeout_s=30.0,
)
start_renderer_rss_mb = _sample_peak_renderer_rss_mb(
browser_process.pid,
profile_dir,
)
await client.evaluate(
"""
(() => {
const textarea = document.querySelector("textarea");
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value"
).set;
valueSetter.call(textarea, "Stream a very long answer.");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
document.querySelector('button[aria-label="Send message"]').click();
return true;
})()
""",
session_id=session_id,
)
await _wait_for_expression(
client,
session_id=session_id,
expression="Boolean(document.querySelector('button[aria-label=\"Stop generating response\"]'))",
timeout_s=10.0,
)
await asyncio.sleep(_POST_SEND_DELAY_S)
peak_renderer_rss_mb = start_renderer_rss_mb
samples: list[tuple[float, float]] = [(0.0, start_renderer_rss_mb)]
start_time = time.monotonic()
while time.monotonic() - start_time < _SAMPLE_WINDOW_S:
current_sample = _sample_peak_renderer_rss_mb(
browser_process.pid,
profile_dir,
)
elapsed_s = round(time.monotonic() - start_time, 2)
samples.append((elapsed_s, current_sample))
peak_renderer_rss_mb = max(peak_renderer_rss_mb, current_sample)
if peak_renderer_rss_mb - start_renderer_rss_mb > _MAX_RENDERER_GROWTH_MB:
break
await asyncio.sleep(_SAMPLE_INTERVAL_S)
renderer_growth_mb = round(peak_renderer_rss_mb - start_renderer_rss_mb, 2)
assert renderer_growth_mb <= _MAX_RENDERER_GROWTH_MB, (
"DevUI renderer memory grew too much during a ~1.5 MB streaming response. "
f"start={start_renderer_rss_mb:.2f}MB "
f"peak={peak_renderer_rss_mb:.2f}MB "
f"growth={renderer_growth_mb:.2f}MB "
f"budget={_MAX_RENDERER_GROWTH_MB:.2f}MB "
f"samples={samples}"
)
finally:
_shutdown_browser_process(browser_process, profile_dir=profile_dir)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+11
View File
@@ -0,0 +1,11 @@
# 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.
## Responses
TODO
## Invocations
TODO
@@ -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"]
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from agent_framework._telemetry import append_to_user_agent
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, Optional
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
def __init__(
self,
agent: BaseAgent,
*,
openapi_spec: Optional[dict[str, Any]] = 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")
append_to_user_agent(self.USER_AGENT_PREFIX)
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."""
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):
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,
})
@@ -0,0 +1,585 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping
from agent_framework import ChatOptions, Content, HistoryProvider, Message, RawAgent, SupportsAgentRun
from agent_framework._telemetry import append_to_user_agent
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,
OutputItem,
OutputItemFunctionToolCall,
OutputItemMessage,
OutputItemOutputMessage,
OutputItemReasoningItem,
OutputMessageContent,
OutputMessageContentOutputTextContent,
OutputMessageContentRefusalContent,
ResponseStreamEvent,
SummaryTextContent,
TextContent,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemFunctionCallBuilder,
OutputItemMcpCallBuilder,
OutputItemMessageBuilder,
OutputItemReasoningItemBuilder,
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from typing_extensions import Any, Sequence, cast
logger = logging.getLogger(__name__)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
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:
The agent must not have a history provider with `load_messages=True`,
because history is managed by the hosting infrastructure.
"""
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."
)
self._agent = agent
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
# Append the user agent prefix for telemetry purposes
append_to_user_agent(self.USER_AGENT_PREFIX)
async def _handler(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
input_text = await context.get_input_text()
history = await context.get_history()
messages = [*_to_messages(history), input_text]
chat_options = _to_chat_options(request)
stream = ResponseEventStream(response_id=context.response_id, model=request.model)
yield stream.emit_created()
yield stream.emit_in_progress()
if request.stream is None or request.stream is False:
# Run the agent in non-streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # pyright: ignore[reportUnknownMemberType]
response = await raw_agent.run(messages, stream=False, options=chat_options)
else:
response = await self._agent.run(messages, stream=False)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(stream, content):
yield item
yield stream.emit_completed()
return
# Start the streaming response
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # pyright: ignore[reportUnknownMemberType]
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
else:
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(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(stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield stream.emit_completed()
# 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, None, None]:
"""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()
assert self._text_content is not None # noqa: S101
self._accumulated.append(content.text)
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()
assert self._summary_part is not None # noqa: S101
self._accumulated.append(content.text)
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)
assert self._fc_builder is not None # noqa: S101
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
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)
assert self._mcp_builder is not None # noqa: S101
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
yield self._mcp_builder.emit_arguments_delta(args_str)
else:
yield from self._close()
self.needs_async = True
def close(self) -> Generator[ResponseStreamEvent, None, None]:
"""Close any remaining active builder."""
yield from self._close()
# -- Private open/close helpers --
def _open_message(self) -> Generator[ResponseStreamEvent, None, None]:
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, None, None]:
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, None, None]:
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, None, None]:
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, None, None]:
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) -> ChatOptions:
"""Converts a CreateResponse request to ChatOptions.
Args:
request (CreateResponse): The request to convert.
Returns:
ChatOptions: The converted ChatOptions.
"""
chat_options = ChatOptions()
if request.temperature is not None:
chat_options["temperature"] = request.temperature
if request.top_p is not None:
chat_options["top_p"] = request.top_p
if request.max_output_tokens is not None:
chat_options["max_tokens"] = request.max_output_tokens
if request.parallel_tool_calls is not None:
chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls
return chat_options
# 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":
msg = cast(OutputItemOutputMessage, item)
contents = [_convert_output_message_content(part) for part in msg.content]
return Message(role=msg.role, contents=contents)
if item.type == "message":
msg = cast(OutputItemMessage, item)
contents = [_convert_message_content(part) for part in msg.content]
return Message(role=msg.role, contents=contents)
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)
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.")
# endregion
@@ -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.0a260402"
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.0b1",
"azure-ai-agentserver-responses==1.0.0b1",
"azure-ai-agentserver-invocations==1.0.0b1",
]
[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"
@@ -0,0 +1,524 @@
# 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
# 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
+35
View File
@@ -0,0 +1,35 @@
# Gemini Package (agent-framework-gemini)
Integration with Google's Gemini API via the `google-genai` SDK.
## Core Classes
- **`RawGeminiChatClient`** - Lightweight chat client without any layers, for custom pipeline composition
- **`GeminiChatClient`** - Full-featured chat client with function invocation, middleware, and telemetry
- **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters
- **`GeminiSettings`** - Settings loaded from environment variables
- **`ThinkingConfig`** - Configuration for extended thinking
## Gemini-specific Options
- **`thinking_config`** - Enable extended thinking via `ThinkingConfig`
- **`response_schema`** - Raw JSON schema dict for structured output (alternative to `response_format`)
- **`top_k`** - Top-K sampling parameter
## Built-in Tool Factory Methods
- **`get_web_search_tool()`** - Google Search grounding for up-to-date web answers
- **`get_code_interpreter_tool()`** - Sandboxed code execution
- **`get_maps_grounding_tool()`** - Google Maps grounding for location and mapping
- **`get_file_search_tool()`** - Retrieval from Gemini file search stores
- **`get_mcp_tool()`** - Model Context Protocol server integration
## Usage
```python
from agent_framework import Content, Message
from agent_framework_gemini import GeminiChatClient
client = GeminiChatClient(model="gemini-2.5-flash")
response = await client.get_response([Message(role="user", contents=[Content.from_text("Hello")])])
```
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+30
View File
@@ -0,0 +1,30 @@
# Get Started with Microsoft Agent Framework Gemini
Install the provider package:
```bash
pip install agent-framework-gemini --pre
```
## Gemini Integration
The Gemini integration enables Microsoft Agent Framework applications to call Google Gemini models with familiar chat abstractions, including streaming, tool/function calling, and structured output.
## Authentication
Obtain an API key from [Google AI Studio](https://aistudio.google.com/apikey) and set it via environment variable:
```bash
export GEMINI_API_KEY="your-api-key"
export GEMINI_MODEL="gemini-2.5-flash"
```
## Examples
See the [Google Gemini samples](samples/) for runnable end-to-end scripts covering:
- Basic agent with tool calling and streaming
- Extended thinking with `ThinkingConfig`
- Google Search grounding
- Google Maps grounding
- Built-in code execution
@@ -0,0 +1,19 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._chat_client import GeminiChatClient, GeminiChatOptions, GeminiSettings, RawGeminiChatClient, ThinkingConfig
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"GeminiChatClient",
"GeminiChatOptions",
"GeminiSettings",
"RawGeminiChatClient",
"ThinkingConfig",
"__version__",
]
@@ -0,0 +1,939 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import json
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Mapping, Sequence
from typing import Any, ClassVar, Generic, cast
from uuid import uuid4
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatOptions,
ChatResponse,
ChatResponseUpdate,
Content,
FinishReasonLiteral,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
Message,
ResponseStream,
UsageDetails,
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework.observability import ChatTelemetryLayer
from google import genai
from google.genai import types
from pydantic import BaseModel
if sys.version_info >= (3, 13):
from typing import TypeVar # type: ignore # pragma: no cover
else:
from typing_extensions import TypeVar # type: ignore # pragma: no cover
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
from typing_extensions import override # type: ignore # pragma: no cover
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger("agent_framework.gemini")
__all__ = [
"GeminiChatClient",
"GeminiChatOptions",
"GeminiSettings",
"RawGeminiChatClient",
"ThinkingConfig",
]
ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None)
# region Options & Settings
class ThinkingConfig(TypedDict, total=False):
"""Extended thinking configuration for Gemini models.
Attributes:
include_thoughts: Whether to include thought summaries in the response. Thought summaries
are condensed representations of the model's internal reasoning and appear as response
parts where ``part.thought`` is ``True``. Note: the framework currently excludes
thought parts from ``ChatResponse.contents`` and does not surface them as output.
thinking_budget: Token budget for Gemini 2.5 models. Set to ``0`` to disable
thinking or ``-1`` to enable a dynamic budget.
thinking_level: Thinking level for Gemini 2.5 models and later. One of
``ThinkingLevel.THINKING_LEVEL_UNSPECIFIED`` (default), ``ThinkingLevel.MINIMAL``,
``ThinkingLevel.LOW``, ``ThinkingLevel.MEDIUM``, or ``ThinkingLevel.HIGH``.
"""
include_thoughts: bool
thinking_budget: int
thinking_level: types.ThinkingLevel
class GeminiChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False):
"""Google Gemini API-specific chat options.
Extends ``ChatOptions`` with Gemini-specific fields. Standard options are mapped to their
``GenerateContentConfig`` equivalents; Gemini-specific fields are declared below.
Only text output is supported for now. Other modalities may be added later.
See: https://ai.google.dev/api/generate-content#generationconfig
Inherited fields from ``ChatOptions``:
model: Model to use for this call (e.g. ``"gemini-2.5-flash"``).
temperature: Controls randomness. Higher values produce more varied output.
max_tokens: Maximum number of tokens to generate (``maxOutputTokens``).
top_p: Nucleus sampling cutoff. Only tokens within the top-p probability mass are considered.
stop: One or more sequences that stop generation when encountered (``stopSequences``).
seed: Fixed seed for reproducible outputs.
frequency_penalty: Reduces repetition by penalising tokens that appear frequently.
presence_penalty: Reduces repetition by penalising tokens that have already appeared.
tools: Function tools the model may call. Accepts ``FunctionTool`` instances, plain callables,
or ``types.Tool`` objects returned by ``get_code_interpreter_tool``, ``get_web_search_tool``,
``get_mcp_tool``, ``get_file_search_tool``, or ``get_maps_grounding_tool``.
tool_choice: How the model picks a tool. One of ``'auto'``, ``'none'``, or ``'required'``.
response_format: Pydantic model type for structured JSON output. The response text is
parsed into the model and exposed via ``ChatResponse.value``.
instructions: Extra system-level instructions prepended to the system message.
Not supported, and passing these raises a type error:
- ``logit_bias``
- ``allow_multiple_tool_calls``
- ``store``
- ``user``
- ``metadata``
- ``conversation_id``
"""
# Gemini's GenerationConfig options
response_schema: dict[str, Any]
"""Raw JSON schema dict for structured output (alternative to ``response_format``).
Sets ``response_mime_type`` to ``'application/json'`` and passes the schema directly."""
top_k: int
"""Top-K sampling: limits token selection to the K most probable tokens."""
thinking_config: ThinkingConfig
"""Extended thinking configuration. See ``ThinkingConfig`` for available fields."""
# Unsupported base options. Override with None to indicate not supported
logit_bias: None # type: ignore[misc]
"""Not supported in the Gemini API."""
allow_multiple_tool_calls: None # type: ignore[misc]
"""Not supported. Gemini handles parallel tool calls automatically."""
store: None # type: ignore[misc]
"""Not supported in the Gemini API."""
user: None # type: ignore[misc]
"""Not supported in the Gemini API."""
metadata: None # type: ignore[misc]
"""Not supported in the Gemini API."""
conversation_id: None # type: ignore[misc]
"""Not supported in the Gemini API."""
GeminiChatOptionsT = TypeVar("GeminiChatOptionsT", bound=TypedDict, default="GeminiChatOptions", covariant=True) # type: ignore[valid-type]
class GeminiSettings(TypedDict, total=False):
"""Gemini configuration settings loaded from environment or .env files."""
api_key: SecretString | None
model: str | None
# endregion
_GEMINI_SERVICE_URL = "https://generativelanguage.googleapis.com"
# Keys mapping to a different GenerateContentConfig field name
_OPTION_TRANSLATIONS: dict[str, str] = {
"max_tokens": "max_output_tokens",
"stop": "stop_sequences",
}
# Keys handled with dedicated logic, not via the generic passthrough
_OPTION_EXPLICIT_KEYS: frozenset[str] = frozenset({
"tools",
"tool_choice",
"response_format",
"response_schema",
"thinking_config",
})
# Keys consumed upstream and not forwarded to GenerateContentConfig
_OPTION_CONSUMED_KEYS: frozenset[str] = frozenset({
"model",
"instructions",
})
_OPTION_EXCLUDE_KEYS: frozenset[str] = _OPTION_EXPLICIT_KEYS | _OPTION_CONSUMED_KEYS
_FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = {
"STOP": "stop",
"MAX_TOKENS": "length",
"SAFETY": "content_filter",
"RECITATION": "content_filter",
"LANGUAGE": "content_filter",
"BLOCKLIST": "content_filter",
"PROHIBITED_CONTENT": "content_filter",
"SPII": "content_filter",
"IMAGE_SAFETY": "content_filter",
"IMAGE_PROHIBITED_CONTENT": "content_filter",
"IMAGE_RECITATION": "content_filter",
"MALFORMED_FUNCTION_CALL": "tool_calls",
"UNEXPECTED_TOOL_CALL": "tool_calls",
}
class RawGeminiChatClient(
BaseChatClient[GeminiChatOptionsT],
Generic[GeminiChatOptionsT],
):
"""A raw Gemini chat client for the Google Gemini API without function invocation, middleware or telemetry.
Use this when you want full control over the request pipeline. For instance, to opt out of
telemetry, use custom middleware, or compose your own layers. If you want the full-featured
client with batteries included, use `GeminiChatClient` instead.
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "gcp.gemini" # type: ignore[reportIncompatibleVariableOverride, misc]
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
client: genai.Client | None = None,
additional_properties: dict[str, Any] | None = None,
) -> None:
"""Create a raw Gemini chat client.
Args:
api_key: Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable.
model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable.
env_file_path: Path to a ``.env`` file for credential loading.
env_file_encoding: Encoding for the ``.env`` file.
client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required.
additional_properties: Extra properties stored on the client instance.
"""
settings = load_settings(
GeminiSettings,
env_prefix="GEMINI_",
api_key=api_key,
model=model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
if client:
self._genai_client = client
else:
resolved_key = settings.get("api_key")
if not resolved_key:
raise ValueError(
"Gemini API key is required. Set via api_key parameter or GEMINI_API_KEY environment variable."
)
self._genai_client = genai.Client(
api_key=resolved_key.get_secret_value(),
http_options={"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}},
)
self.model = settings.get("model")
super().__init__(additional_properties=additional_properties)
@staticmethod
def get_code_interpreter_tool() -> types.Tool:
"""Create a code execution tool.
Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``.
Returns:
A ``types.Tool`` configured for sandboxed code execution.
"""
return types.Tool(code_execution=types.ToolCodeExecution())
@staticmethod
def get_web_search_tool(
*,
search_types: types.SearchTypes | None = None,
blocking_confidence: types.PhishBlockThreshold | None = None,
exclude_domains: list[str] | None = None,
time_range_filter: types.Interval | None = None,
) -> types.Tool:
"""Create a Google Search grounding tool.
Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``.
Args:
search_types: Controls which search types are enabled (web search, image search).
blocking_confidence: Block sites at or above this phishing confidence level.
Not supported in Gemini API.
exclude_domains: List of domains to exclude from search results. Not supported in Gemini API.
time_range_filter: Restrict results to a specific time range. Not supported in Vertex AI.
Returns:
A ``types.Tool`` configured for Google Search grounding.
"""
return types.Tool(
google_search=types.GoogleSearch(
search_types=search_types,
blocking_confidence=blocking_confidence,
exclude_domains=exclude_domains,
time_range_filter=time_range_filter,
)
)
@staticmethod
def get_mcp_tool(url: str, *, name: str | None = None, **kwargs: Any) -> types.Tool:
"""Create an MCP (Model Context Protocol) server tool.
Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``.
Args:
url: The URL of the MCP server's streamable HTTP endpoint.
name: Optional display name for the MCP server.
**kwargs: Additional kwargs passed to ``StreamableHttpTransport``. Supported fields
include ``headers``, ``timeout``, ``sse_read_timeout``, and ``terminate_on_close``.
Returns:
A ``types.Tool`` configured for the given MCP server.
"""
return types.Tool(
mcp_servers=[
types.McpServer(
name=name,
streamable_http_transport=types.StreamableHttpTransport(url=url, **kwargs),
)
]
)
@staticmethod
def get_file_search_tool(
*,
file_search_store_names: list[str] | None = None,
top_k: int | None = None,
metadata_filter: str | None = None,
) -> types.Tool:
"""Create a file search tool backed by a Gemini file search store.
Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``.
Args:
file_search_store_names: Resource names of the file search stores to query.
Example: ``["fileSearchStores/my-file-search-store-123"]``.
top_k: Maximum number of retrieval chunks to return.
metadata_filter: CEL expression to filter retrieval results by metadata.
See https://google.aip.dev/160 for syntax.
Returns:
A ``types.Tool`` configured for file search retrieval.
"""
return types.Tool(
file_search=types.FileSearch(
file_search_store_names=file_search_store_names,
top_k=top_k,
metadata_filter=metadata_filter,
)
)
@staticmethod
def get_maps_grounding_tool(
*,
enable_widget: bool | None = None,
auth_config: types.AuthConfig | None = None,
) -> types.Tool:
"""Create a Google Maps grounding tool.
Pass the returned tool to the ``tools`` list of an agent or ``ChatOptions``.
Args:
enable_widget: Return a widget context token in ``GroundingMetadata`` so callers
can render a Google Maps widget with geospatial context.
auth_config: Authentication config to access the Maps API. Only API key is
supported. Not supported in Gemini API.
Returns:
A ``types.Tool`` configured for Google Maps grounding.
"""
return types.Tool(google_maps=types.GoogleMaps(enable_widget=enable_widget, auth_config=auth_config))
@override
def _inner_get_response(
self,
*,
messages: Sequence[Message],
options: Mapping[str, Any],
stream: bool = False,
**kwargs: Any,
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
validated = await self._validate_options(options)
model, contents, config = self._prepare_request(messages, validated)
async for chunk in await self._genai_client.aio.models.generate_content_stream( # pyright: ignore[reportUnknownMemberType]
model=model,
contents=contents, # type: ignore[arg-type]
config=config,
):
yield self._process_chunk(chunk)
return self._build_response_stream(_stream(), response_format=options.get("response_format"))
async def _get_response() -> ChatResponse:
validated = await self._validate_options(options)
model, contents, config = self._prepare_request(messages, validated)
raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type]
return self._process_generate_response(raw, response_format=validated.get("response_format"))
return _get_response()
@override
def service_url(self) -> str:
"""Return the base URL of the Gemini API service.
Returns:
The Gemini API base URL.
"""
return _GEMINI_SERVICE_URL
# region Request preparation
def _prepare_request(
self,
messages: Sequence[Message],
options: Mapping[str, Any],
) -> tuple[str, list[types.Content], types.GenerateContentConfig]:
"""Resolve the model ID, convert messages to Gemini contents, and build the generation config.
Call this after awaiting ``_validate_options`` so that tools and other options are
fully normalized before the request is assembled.
Args:
messages: The conversation history as framework Message objects.
options: Validated and normalized chat options.
Returns:
A tuple of the resolved model, the Gemini contents list, and the generation config.
Raises:
ValueError: If no model is set on the options or the client instance.
"""
model = options.get("model") or self.model
if not model:
raise ValueError("Gemini model is required. Set via model parameter or GEMINI_MODEL environment variable.")
system_instruction, contents = self._prepare_gemini_messages(messages)
if call_instructions := options.get("instructions"):
system_instruction = (
f"{call_instructions}\n{system_instruction}" if system_instruction else call_instructions
)
return model, contents, self._prepare_config(options, system_instruction)
def _prepare_gemini_messages(self, messages: Sequence[Message]) -> tuple[str | None, list[types.Content]]:
"""Convert framework messages to Gemini contents and extract system instruction.
Args:
messages: The full conversation history as framework Message objects.
Returns:
A tuple of (system_instruction_text, contents_list). System messages are extracted
into the instruction string; tool results are grouped into user-role content blocks.
"""
system_parts: list[str] = []
contents: list[types.Content] = []
# Maps call_id to function name so function_result parts can include the required name field.
call_id_to_name: dict[str, str] = {}
# Accumulated functionResponse parts from consecutive tool messages.
pending_tool_parts: list[types.Part] = []
def flush_pending_tool_parts() -> None:
if pending_tool_parts:
contents.append(types.Content(role="user", parts=list(pending_tool_parts)))
pending_tool_parts.clear()
for message in messages:
if message.role == "system":
if message.text:
system_parts.append(message.text)
continue
if message.role == "tool":
for content in message.contents:
part = self._convert_function_result(content, call_id_to_name)
if part is not None:
pending_tool_parts.append(part)
continue
# Non-tool message — flush any accumulated tool parts first.
flush_pending_tool_parts()
parts = self._convert_message_contents(message.contents, call_id_to_name)
if not parts:
continue
role = "model" if message.role == "assistant" else "user"
contents.append(types.Content(role=role, parts=parts))
flush_pending_tool_parts()
system_instruction = "\n".join(system_parts) if system_parts else None
return system_instruction, contents
def _convert_message_contents(
self,
message_contents: Sequence[Content],
call_id_to_name: dict[str, str],
) -> list[types.Part]:
"""Convert framework Content objects to Gemini Part objects, tracking function call IDs.
Args:
message_contents: The content items of a single framework message.
call_id_to_name: Mutable mapping updated with any function call ID-to-name pairs found.
Returns:
A list of Gemini Part objects representing the message contents.
"""
parts: list[types.Part] = []
for content in message_contents:
match content.type:
case "text":
parts.append(types.Part(text=content.text or ""))
case "function_call":
call_id = content.call_id or self._generate_tool_call_id()
if content.name:
call_id_to_name[call_id] = content.name
parts.append(
types.Part(
function_call=types.FunctionCall(
id=call_id,
name=content.name or "",
args=content.parse_arguments() or {},
)
)
)
case _:
logger.debug("Skipping unsupported content type for Gemini: %s", content.type)
return parts
def _convert_function_result(
self,
content: Content,
call_id_to_name: dict[str, str],
) -> types.Part | None:
"""Convert a function_result Content to a Gemini FunctionResponse Part.
Args:
content: The framework Content object, expected to be of type ``function_result``.
call_id_to_name: Mapping of call IDs to function names, used to resolve the required name field.
Returns:
A Gemini Part containing a FunctionResponse, or None if the content type is not
``function_result`` or the call ID cannot be resolved.
"""
if content.type != "function_result":
return None
name = call_id_to_name.get(content.call_id or "")
if not name:
logger.warning(
"Skipping function_result: no matching function_call found for call_id=%r",
content.call_id,
)
return None
response = self._coerce_to_dict(content.result)
return types.Part(
function_response=types.FunctionResponse(
id=content.call_id,
name=name,
response=response,
)
)
@staticmethod
def _coerce_to_dict(value: Any) -> dict[str, Any]:
"""Ensure a tool result value is a dict as required by Gemini's FunctionResponse.
Args:
value: The raw tool result. May be a dict, JSON string, plain string, None, or any other value.
Returns:
A dict representation of the value. JSON strings are parsed; all other non-dict values
are wrapped as ``{"result": <str(value)>}``.
"""
if isinstance(value, dict):
return cast(dict[str, Any], value)
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return cast(dict[str, Any], parsed)
except (json.JSONDecodeError, ValueError):
pass
return {"result": value}
if value is None:
return {"result": ""}
return {"result": str(value)}
def _prepare_config(
self,
options: Mapping[str, Any],
system_instruction: str | None,
) -> types.GenerateContentConfig:
"""Build a ``types.GenerateContentConfig`` from the resolved chat options.
Note: ``_OPTION_TRANSLATIONS`` keys are renamed, ``_OPTION_EXCLUDE_KEYS`` are skipped, and all
remaining keys are forwarded as-is, allowing new Gemini parameters to be adopted without
framework changes.
Args:
options: Resolved chat options mapping, typically a ``GeminiChatOptions`` dict.
system_instruction: Combined system instruction text, or None if absent.
Returns:
A fully populated ``GenerateContentConfig`` ready to pass to the Gemini API.
"""
kwargs: dict[str, Any] = {}
if system_instruction:
kwargs["system_instruction"] = system_instruction
for key, value in options.items():
if key in _OPTION_EXCLUDE_KEYS or value is None:
continue
kwargs[_OPTION_TRANSLATIONS.get(key, key)] = value
if options.get("response_format") or options.get("response_schema"):
kwargs["response_mime_type"] = "application/json"
if schema := options.get("response_schema"):
kwargs["response_schema"] = schema
if tools := self._prepare_tools(options):
kwargs["tools"] = tools
if tool_config := self._prepare_tool_config(options.get("tool_choice")):
kwargs["tool_config"] = tool_config
if thinking_config := options.get("thinking_config"):
thinking_config_kwargs = {k: v for k, v in thinking_config.items() if v is not None}
if thinking_config_kwargs:
kwargs["thinking_config"] = types.ThinkingConfig(**thinking_config_kwargs)
return types.GenerateContentConfig(**kwargs)
def _prepare_tools(self, options: Mapping[str, Any]) -> list[types.Tool] | None:
"""Translate the framework tool list into Gemini API tool objects.
The Gemini API does not accept framework ``FunctionTool`` objects directly.
This method acts as the translation boundary between the two type systems.
It handles two kinds of entries in ``options["tools"]``:
- ``FunctionTool``: a framework abstraction for a callable with a name,
description, and JSON schema. Translated to ``types.FunctionDeclaration``
(Gemini's equivalent) and grouped into a single ``types.Tool``, which is
how the Gemini API expects function declarations to be passed.
- ``types.Tool``: already in Gemini's native format (e.g. built-in tools
such as search or code execution). Passed through unchanged. Use the
``get_*_tool`` factory methods on this class to produce these.
Args:
options: Resolved chat options whose ``tools`` entry may contain
``FunctionTool`` instances, plain callables, or ``types.Tool`` objects.
Returns:
A non-empty list of ``types.Tool`` objects ready for the Gemini API,
or ``None`` if no tools are configured.
"""
tools_option: list[Any] = options.get("tools") or []
result: list[types.Tool] = []
# Translate framework FunctionTool objects to Gemini API FunctionDeclaration objects
declarations = [
types.FunctionDeclaration(
name=tool.name,
description=tool.description or "",
parameters=tool.parameters(), # type: ignore[arg-type]
)
for tool in tools_option
if isinstance(tool, FunctionTool)
]
if declarations:
result.append(types.Tool(function_declarations=declarations))
# Objects of type types.Tool are already in Gemini's native format
result.extend(tool for tool in tools_option if isinstance(tool, types.Tool))
return result or None
def _prepare_tool_config(self, tool_choice: Any) -> types.ToolConfig | None:
"""Build a Gemini ``ToolConfig`` from the framework ``tool_choice`` value.
Args:
tool_choice: Raw ``tool_choice`` value from options (string, dict, or None).
Returns:
A ``types.ToolConfig`` with the appropriate ``FunctionCallingConfig``, or None
if no ``tool_choice`` is set or the mode is unsupported.
"""
tool_mode = validate_tool_mode(tool_choice)
if not tool_mode:
return None
match tool_mode.get("mode"):
case "auto":
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None
case "none":
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.NONE, None
case "required":
function_calling_mode = types.FunctionCallingConfigMode.ANY
name = tool_mode.get("required_function_name")
allowed_names = [name] if name else None
case unknown_mode:
logger.warning("Unsupported tool_choice mode for Gemini: %s", unknown_mode)
return None
function_calling_kwargs: dict[str, Any] = {"mode": function_calling_mode}
if allowed_names:
function_calling_kwargs["allowed_function_names"] = allowed_names
return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(**function_calling_kwargs))
# endregion
# region Response parsing
def _process_generate_response(
self,
response: types.GenerateContentResponse,
*,
response_format: type[BaseModel] | None = None,
) -> ChatResponse:
"""Convert a Gemini generate_content response to a framework ChatResponse.
Args:
response: The raw ``GenerateContentResponse`` from the Gemini API.
response_format: Optional Pydantic model type for structured output parsing.
When provided, the response text is parsed into the given model and
made available via ``ChatResponse.value``.
Returns:
A ``ChatResponse`` with parsed messages, usage details, finish reason, and model ID.
"""
candidate = response.candidates[0] if response.candidates else None
parts: list[types.Part] = (candidate.content.parts or []) if candidate and candidate.content else []
contents = self._parse_parts(parts)
return ChatResponse(
response_id=None,
messages=[Message(role="assistant", contents=contents, raw_representation=candidate)],
usage_details=self._parse_usage(response.usage_metadata),
model=response.model_version or self.model,
finish_reason=self._map_finish_reason(
candidate.finish_reason.name if candidate and candidate.finish_reason else None
),
response_format=response_format,
raw_representation=response,
)
def _process_chunk(self, chunk: types.GenerateContentResponse) -> ChatResponseUpdate:
"""Convert a single streaming chunk to a framework ChatResponseUpdate.
Usage details are attached only to the final chunk, identified by a non-None finish reason.
Args:
chunk: A streaming ``GenerateContentResponse`` chunk from the Gemini API.
Returns:
A ``ChatResponseUpdate`` with parsed contents, finish reason, and model ID.
"""
candidate = chunk.candidates[0] if chunk.candidates else None
parts: list[types.Part] = (candidate.content.parts or []) if candidate and candidate.content else []
contents = self._parse_parts(parts)
finish_reason = self._map_finish_reason(
candidate.finish_reason.name if candidate and candidate.finish_reason else None
)
# Attach usage to the final chunk only (when finish_reason is set).
if finish_reason and (usage := self._parse_usage(chunk.usage_metadata)):
contents.append(Content.from_usage(usage_details=usage))
return ChatResponseUpdate(
contents=contents,
model=chunk.model_version,
finish_reason=finish_reason,
raw_representation=chunk,
)
def _parse_parts(self, parts: Sequence[types.Part]) -> list[Content]:
"""Convert Gemini response parts to framework Content objects, skipping thought/reasoning parts.
Args:
parts: Sequence of ``types.Part`` objects from a Gemini response candidate.
Returns:
A list of framework ``Content`` objects (text, function_call, or function_result).
"""
contents: list[Content] = []
for part in parts:
if part.thought:
continue
if part.text is not None:
contents.append(Content.from_text(text=part.text, raw_representation=part))
elif part.function_call is not None:
function_call = part.function_call
if function_call.id:
call_id = function_call.id
else:
call_id = self._generate_tool_call_id()
logger.debug("function_call missing id; generated fallback call_id=%r", call_id)
contents.append(
Content.from_function_call(
call_id=call_id,
name=function_call.name or "",
arguments=function_call.args or {},
raw_representation=part,
)
)
elif part.function_response is not None:
function_response = part.function_response
contents.append(
Content.from_function_result(
call_id=function_response.id or self._generate_tool_call_id(),
result=function_response.response,
raw_representation=part,
)
)
elif part.executable_code is not None:
if part.executable_code.code:
contents.append(Content.from_text(text=part.executable_code.code, raw_representation=part))
elif part.code_execution_result is not None:
if part.code_execution_result.output:
contents.append(Content.from_text(text=part.code_execution_result.output, raw_representation=part))
else:
logger.debug("Skipping unsupported response part from Gemini")
return contents
def _parse_usage(self, usage: types.GenerateContentResponseUsageMetadata | None) -> UsageDetails | None:
"""Extract token usage counts from Gemini usage metadata.
Args:
usage: The ``GenerateContentResponseUsageMetadata`` from the API response, or None.
Returns:
A ``UsageDetails`` dict with available token counts, or None if no usage data is present.
"""
if not usage:
return None
details: UsageDetails = {}
if (v := usage.prompt_token_count) is not None:
details["input_token_count"] = v
if (v := usage.candidates_token_count) is not None:
details["output_token_count"] = v
if (v := usage.total_token_count) is not None:
details["total_token_count"] = v
return details or None
def _map_finish_reason(self, reason: str | None) -> FinishReasonLiteral | None:
"""Map a Gemini finish reason string to the framework's FinishReasonLiteral.
Args:
reason: The finish reason name from the Gemini API (e.g. ``"STOP"``), or None.
Returns:
The corresponding ``FinishReasonLiteral``, or None if the reason is absent or unmapped.
"""
if not reason:
return None
return _FINISH_REASON_MAP.get(reason)
# endregion
@staticmethod
def _generate_tool_call_id() -> str:
"""Generate a unique fallback ID for tool calls that lack one.
Returns:
A unique string in the format ``tool-call-<uuid_hex>``.
"""
return f"tool-call-{uuid4().hex}"
class GeminiChatClient(
FunctionInvocationLayer[GeminiChatOptionsT],
ChatMiddlewareLayer[GeminiChatOptionsT],
ChatTelemetryLayer[GeminiChatOptionsT],
RawGeminiChatClient[GeminiChatOptionsT],
Generic[GeminiChatOptionsT],
):
"""Gemini chat client for the Google Gemini API with function invocation, middleware, and telemetry.
This is the recommended client for most use cases. It builds on ``RawGeminiChatClient``
and adds:
- **Function invocation**: automatically calls ``FunctionTool`` implementations and feeds
results back to the model until it produces a final text response.
- **Middleware**: a composable chain for cross-cutting concerns (logging, retries, etc.).
- **Telemetry**: OpenTelemetry traces and metrics emitted for every request.
Use ``RawGeminiChatClient`` instead when you need full control over the request pipeline
and want to opt out of one or more of these layers.
"""
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
client: genai.Client | None = None,
additional_properties: dict[str, Any] | None = None,
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
) -> None:
"""Create a Gemini chat client.
Args:
api_key: The Google AI Studio API key. Falls back to ``GEMINI_API_KEY`` environment variable.
model: Default model identifier. Falls back to ``GEMINI_MODEL`` environment variable.
env_file_path: Path to a ``.env`` file for credential loading.
env_file_encoding: Encoding for the ``.env`` file.
client: Pre-built ``genai.Client`` instance. When provided, ``api_key`` is not required.
additional_properties: Extra properties stored on the client instance.
middleware: Optional middleware chain applied to every call.
function_invocation_configuration: Optional configuration for the function invocation loop.
"""
super().__init__(
api_key=api_key,
model=model,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
client=client,
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
)
+105
View File
@@ -0,0 +1,105 @@
[project]
name = "agent-framework-gemini"
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"
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",
"Programming Language :: Python :: 3.14",
"Framework :: Pydantic :: 2",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2.0",
"google-genai>=1.0.0,<2.0.0",
]
[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 = []
markers = [
"integration: marks tests as integration tests that require external services",
"flaky: marks tests as flaky and eligible for automatic retry",
]
timeout = 120
[tool.ruff]
extend = "../../pyproject.toml"
[tool.ruff.lint.extend-per-file-ignores]
"samples/**" = ["S", "T201"]
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_gemini"]
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_gemini"]
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_gemini"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_gemini --cov-report=term-missing:skip-covered tests'
[tool.flit.module]
name = "agent_framework_gemini"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
+18
View File
@@ -0,0 +1,18 @@
# Google Gemini Examples
This folder contains examples demonstrating how to use Google Gemini models with the Agent Framework.
## Examples
| File | Description |
|------|-------------|
| [`gemini_basic.py`](gemini_basic.py) | Basic agent with a weather tool, demonstrating both streaming and non-streaming responses. |
| [`gemini_advanced.py`](gemini_advanced.py) | Extended thinking via `ThinkingConfig` for reasoning-heavy questions (Gemini 2.5+). |
| [`gemini_with_google_search.py`](gemini_with_google_search.py) | Google Search grounding for up-to-date answers. |
| [`gemini_with_google_maps.py`](gemini_with_google_maps.py) | Google Maps grounding for location and mapping information. |
| [`gemini_with_code_execution.py`](gemini_with_code_execution.py) | Built-in code execution tool for computing precise answers in a sandboxed environment. |
## Environment Variables
- `GEMINI_API_KEY`: Your Google AI Studio API key (get one from [Google AI Studio](https://aistudio.google.com/apikey))
- `GEMINI_MODEL`: The Gemini model to use (e.g., `gemini-2.5-flash`, `gemini-2.5-pro`)
@@ -0,0 +1,47 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shows how to enable extended thinking with ThinkingConfig.
Allows the model to reason through complex problems before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
"""
import asyncio
from agent_framework import Agent
from dotenv import load_dotenv
from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, ThinkingConfig
load_dotenv()
async def main() -> None:
"""Example of extended thinking with a Python version comparison question."""
print("=== Extended thinking ===")
options: GeminiChatOptions = {
"thinking_config": ThinkingConfig(thinking_budget=2048),
}
agent = Agent(
client=GeminiChatClient(),
name="PythonAgent",
instructions="You are a helpful Python expert.",
default_options=options,
)
query = "What new language features were introduced in Python between 3.10 and 3.14?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shows how to use GeminiChatClient with an agent and a custom tool.
Covers both non-streaming and streaming responses.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
"""
import asyncio
from random import randint
from typing import Annotated
from agent_framework import Agent, tool
from dotenv import load_dotenv
from agent_framework_gemini import GeminiChatClient
load_dotenv()
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production
@tool(approval_mode="never_require")
def get_weather(
location: Annotated[str, "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."
async def non_streaming_example() -> None:
"""Runs the agent and waits for the complete response before printing it."""
print("=== Non-streaming ===")
agent = Agent(
client=GeminiChatClient(),
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=[get_weather],
)
query = "What's the weather like in Karlsruhe, Germany?"
print(f"User: {query}")
result = await agent.run(query)
print(f"Result: {result}\n")
async def streaming_example() -> None:
"""Runs the agent and prints each chunk as it is received."""
print("=== Streaming ===")
agent = Agent(
client=GeminiChatClient(),
name="WeatherAgent",
instructions="You are a helpful weather agent.",
tools=[get_weather],
)
query = "What's the weather like in Portland and in Paris?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
async def main() -> None:
"""Run non-streaming and streaming examples."""
await non_streaming_example()
await streaming_example()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shows how to enable Gemini's built-in code execution tool.
Allows the model to write and run code in a sandboxed environment to answer questions.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
"""
import asyncio
from agent_framework import Agent
from dotenv import load_dotenv
from agent_framework_gemini import GeminiChatClient
load_dotenv()
async def main() -> None:
"""Run the code execution example."""
print("=== Code execution ===")
agent = Agent(
client=GeminiChatClient(),
name="CodeAgent",
instructions="You are a helpful assistant. Use code execution to compute precise answers.",
tools=[GeminiChatClient.get_code_interpreter_tool()],
)
query = "What are the first 20 prime numbers? Compute them in code."
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shows how to enable Google Maps grounding.
Allows Gemini to retrieve location and mapping information before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
"""
import asyncio
from agent_framework import Agent
from dotenv import load_dotenv
from agent_framework_gemini import GeminiChatClient
load_dotenv()
async def main() -> None:
"""Run the Google Maps grounding example."""
print("=== Google Maps grounding ===")
agent = Agent(
client=GeminiChatClient(),
name="MapsAgent",
instructions="You are a helpful travel assistant. Use Google Maps to provide accurate location information.",
tools=[GeminiChatClient.get_maps_grounding_tool()],
)
query = "What are some highly rated restaurants in the city center of Karlsruhe, Germany?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,43 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shows how to enable Google Search grounding.
Allows Gemini to retrieve up-to-date information from the web before responding.
Requires the following environment variables to be set:
- GEMINI_API_KEY
- GEMINI_MODEL
"""
import asyncio
from agent_framework import Agent
from dotenv import load_dotenv
from agent_framework_gemini import GeminiChatClient
load_dotenv()
async def main() -> None:
"""Run the Google Search grounding example."""
print("=== Google Search grounding ===")
agent = Agent(
client=GeminiChatClient(),
name="SearchAgent",
instructions="You are a helpful assistant. Use Google Search to provide accurate, up-to-date answers.",
tools=[GeminiChatClient.get_web_search_tool()],
)
query = "What is the latest stable release of the .NET SDK?"
print(f"User: {query}")
print("Agent: ", end="", flush=True)
async for chunk in agent.run(query, stream=True):
if chunk.text:
print(chunk.text, end="", flush=True)
print("\n")
if __name__ == "__main__":
asyncio.run(main())
File diff suppressed because it is too large Load Diff

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