Compare commits

..
Author SHA1 Message Date
Giles OdigweandGitHub ea370f8ff6 sharepoint sample fix (#3108) 2026-01-06 22:57:54 +00:00
Evan MattsonandGitHub 24c822590f fix: tool_choice parameter not being honored when passed to agent.run() (#3095) 2026-01-06 22:51:20 +00:00
CopilotGitHubwestey-mcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Mark WallaceChris
953fde69ac .NET: Fix message ordering inconsistency when using AIContextProvider (#2659)
* Initial plan

* Fix message ordering inconsistency when using AIContextProvider

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Revert to original message ordering: Input, AIContextProvider, Response

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Reorder messages to ChatClient to match MessageStore order: Existing, Input, AIContextProvider

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

* Remove redundant test methods as existing tests already verify the behavior

Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: westey-m <164392973+westey-m@users.noreply.github.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2026-01-06 15:39:42 +00:00
westeyandGitHub 7a05849609 Fix broken strands urls. (#3102)
* Fix broken strands urls.

* Fix typos
2026-01-06 14:55:29 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
0aa0579b1b .NET: Seal ChatClientAgentThread (#2842)
* Initial plan

* Seal ChatClientAgentThread class

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-01-06 10:44:13 +00:00
Evan MattsonandGitHub 844d345106 Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)
* Fix ExecutorInvokedEvent.data mutation bug

* Fix bug related to not yielding output type
2026-01-06 09:12:26 +00:00
takanori-teraiandGitHub ed5278c41d Fix: Update OTLP exporter protocol conditions (#3070) 2026-01-06 04:45:29 +00:00
Evan MattsonandGitHub 928c9d54ad Python: Fix AzureAIClient failure when conversation history contains assistant messages (#3076)
* Fix AzureAIClient failure when conversation history contains assistant messages

* Address PR review feedback: improve docstring and test assertions

* Remove redundant cast
2026-01-05 22:05:46 +00:00
westeyandGitHub 0aba02c402 [BREAKING] Remove unused AgentThreadMetadata (#3067)
* Remove unused AgentThreadMetadata

* Update DurableTask Changelog
2026-01-05 14:03:18 +00:00
3ef67eff10 .NET: [BREAKING] Refactor ChatMessageStore methods to be similar to AIContextProvider and add filtering support (#2604)
* Refactor ChatMessageStore methods to be similar to AIContextProvider

* Fix file encoding

* Ensure that AIContextProvider messages area also persisted.

* Update formatting and seal context classes

* Improve formatting

* Remove optional messages from constructor and add unit test

* Add ChatMessageStore filtering via a decorator

* Update sample and cosmos message store to store AIContextProvider messages in right order. Fix unit tests.

* Update Workflowmessage store to use aicontext provider messages.

* Apply suggestions from code review

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

* Apply suggestions from code review

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

* Improve xml docs messaging

* Address code review comments.

* Also notify message store on failure

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
2026-01-05 11:51:15 +00:00
Eduard van ValkenburgandGitHub deea844bc7 fix and extra int test (#3037) 2026-01-05 04:35:10 +00:00
Eduard van ValkenburgandGitHub 577ad4b838 add issue template and additional labeling (#3006) 2026-01-05 01:32:33 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>SergeyMenshykhChris
8b4f7d5e29 .NET: [Breaking] Introduce RunCoreAsync/RunCoreStreamingAsync delegation pattern in AIAgent (#2749)
* Initial plan

* Refactor AIAgent: Make RunAsync and RunStreamingAsync non-abstract, add RunCoreAsync and RunCoreStreamingAsync

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

* Fix infinite recursion in test implementations

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

* Make RunAsync and RunStreamingAsync non-virtual as requested

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

* Fix DelegatingAIAgent subclasses to use RunCoreAsync/RunCoreStreamingAsync

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

* Fix XML documentation references in AnonymousDelegatingAIAgent

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

* Restore <see cref> tags with proper qualified signatures in AnonymousDelegatingAIAgent

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

* Rollback unnecessary XML documentation changes in AnonymousDelegatingAIAgent

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

* Remove pragma and update crefs to RunCoreAsync/RunCoreStreamingAsync

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

* Fix EntityAgentWrapper to call base.RunCoreAsync/RunCoreStreamingAsync

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

* fix compilation issues

* fix compilatio issue

* fix tests

* fix unit tests

* fix unit test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-30 12:24:09 +00:00
Eduard van ValkenburgandGitHub 4b8a545589 Python: add powerfx safe mode (#3028)
* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset
2025-12-23 20:12:50 +00:00
Dmytro StrukandGitHub 5ab47596ff Python: Updated package versions (#3024)
* Updated package versions

* Updated changelog
2025-12-23 16:04:53 +00:00
Eduard van ValkenburgandGitHub a32702cf38 Python: latency improvements (#3014)
* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement
2025-12-23 16:04:34 +00:00
8b743af217 Fix typo in README.md about agent definitions (#2634)
* Fix typo in README.md about agent definitions

* Update agent-samples/README.md

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

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-22 14:39:52 +00:00
Chris GillumandGitHub 0e152a0e33 .NET: [Durable Agents] Reliable streaming sample (#2942)
* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback
2025-12-19 23:43:36 +00:00
3b77192ad0 Python: Introducing support for Bedrock-hosted models (Anthropic, Cohere, etc.) (#2610)
* Pushing the bedrock related changes to the new branch after addressing the review comments

* 2524 Addressed the second round review comments

* 2524 Addressed few more minor comments on the PR

* resolving the merge conflict

* 2524 resolved the uv.lock conflicts

* 2524 addressed more comments

* 2524 removed the print statement to fix the checks failure

* 2524 resolved the CI failure issues

* 2524 fixing the CI breaks

* 2524 Addressed the review comment

* 2524 resolved conflict

---------

Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
2025-12-19 18:35:53 +00:00
Hao LuoandGitHub defe0f1a89 Python: Added response.created and response.in_progress event process to OpenAIBaseResponseClient (#2975)
* added response.created and response.in_progress to include response.id

* better doc string

* added tests for the new streaming event types
2025-12-19 17:50:15 +00:00
SuperKenVeryandGitHub 85d70f01f6 Python: Preserve reasoning blocks with OpenRouter (#2950)
* Preserve reasoning blocks with OpenRouter

* Put encrypted reasoning in TextReasoningContent

* Remove unneccessary change

* Fix docs

* Support streaming

* Fix handling None in TextReasoningContent.text
2025-12-19 17:03:19 +00:00
Giles OdigweandGitHub 6930c0f0b6 Python: Added GitHub MCP sample with PAT (#2967)
* added github mcp sample with PAT

* addressed copilot fixes

* env fix
2025-12-19 16:46:12 +00:00
Dmytro StrukandGitHub d83cf93f07 Updated package versions (#2978) 2025-12-19 16:16:49 +00:00
Eduard van ValkenburgandGitHub 8783ac58f1 Python: Introducing Foundry Local Chat Clients (#2915)
* redo foundry local chat client

* fix mypy and spelling

* better docstring, updated sample

* fixed tests and added tests

* small sample update
2025-12-19 16:05:55 +00:00
Evan MattsonandGitHub e15eab7da6 Python: Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG (#2968)
* Bump Py version to 1.0.0b251218 for a release. Update CHANGELOG

* update lock

* Fix formatting

* Fix ChatKit typing
2025-12-19 01:31:57 +00:00
Jacob ViauandGitHub 19a9e13788 .NET: Use GrpcEntityRunner instead of TaskEntityDispatcher (#2759)
* Use GrpcEntityRunner instead of TaskEntityDispatcher

* Pin to Durable worker 1.11.0

* Set the invocation result

* Update all Durable packages

* Update changelog, rename dispatcher to encondedEntityRequest
2025-12-19 00:55:33 +00:00
Evan MattsonandGitHub b0a7a1fcb8 Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946)
* Fix kwargs propagation through workflow.as_agent()

* Fix WorkflowAgent to respect AgentExecutor output_response setting
2025-12-18 19:35:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
a841bdd1cc Bump Azure.AI.AgentServer.AgentFramework from 1.0.0-beta.4 to 1.0.0-beta.5 (#2854)
---
updated-dependencies:
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-18 18:36:13 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Mark Wallace
d46adffe6c Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.11 to 4.0.5 (#2853)
---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
2025-12-18 17:25:54 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b0b5777363 Bump CommunityToolkit.Aspire.OllamaSharp from 13.0.0-beta.440 to 13.0.0 (#2856)
---
updated-dependencies:
- dependency-name: CommunityToolkit.Aspire.OllamaSharp
  dependency-version: 13.0.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-18 17:25:36 +00:00
Giles OdigweandGitHub 37b4cfd024 Python: Add Azure Managed Redis Support with Credential Provider (#2887)
* azure redis support

* small fixes

* azure managed redis sample

* fixes
2025-12-18 17:10:55 +00:00
CopilotGitHubstephentoubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
ff9343d7cc .NET: Update Anthropic package to version 12.0.0 (#2914)
* Initial plan

* Update Anthropic package to version 12.0.0

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
2025-12-18 16:02:20 +00:00
Victor DibiaandGitHub 8ff34f9a43 Python: Add workflow cancellation sample (#2732)
* Add workflow cancellation sample

Add sample demonstrating how to cancel a running workflow using asyncio
tasks. Shows both cancellation mid-execution and normal completion paths.
Useful for implementing timeouts, graceful shutdown, or A2A executors.

* update docstring
2025-12-18 14:12:42 +00:00
Hao LuoandGitHub e3f8bfc645 Python: Fixes Run ID and Thread ID casing to align with AG-UI Typescript SDK (#2948)
* added camelCase input to run id and thread id aligning with @ag-ui/core

* fixed per copilot suggestions
2025-12-18 14:10:16 +00:00
Tao ChenandGitHub b4f2709b6d Python: Workflow add option to visualize internal executors (#2917)
* Workflow add option to visualize internal executors

* Address Copilot comments
2025-12-18 14:04:03 +00:00
Eduard van ValkenburgandGitHub e5c11d38d6 Python: cleanup and refactoring of chat clients (#2937)
* refactoring and unifying naming schemes of internal methods of chat clients

* set tool_choice to auto

* fix for mypy

* added note on naming and fix #2951

* fix responses

* fixes in azure ai agents client
2025-12-18 12:02:23 +00:00
a71f768331 .NET: [Breaking] Delete display name property (#2758)
* delete the AIAgent.DisplayName property

* use agent name as a first value for activity display name

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-18 09:22:45 +00:00
0298e0a401 Python: fix: correct BadRequestError when using Pydantic model in response_fo… (#1843)
* fix: correct BadRequestError when using Pydantic model in response_format

* Fix lint

---------

Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
2025-12-18 08:42:00 +00:00
Evan MattsonandGitHub ca1532cf22 Python: Move ollama samples to samples getting started dir (#2921)
* Move ollama samples to samples getting started dir

* Address feedback
2025-12-18 08:37:05 +00:00
Evan MattsonandGitHub 360839782c Pass kwargs into subworkflows (#2923) 2025-12-18 04:34:33 +00:00
Ege Ozan ÖzyedekandGitHub ee53fe4666 Python: Correction of MCP image type conversion in _mcp.py (#2901)
* Correction of MCP image type conversion in  _mcp.py

* Added a new overload to the init function of the DataContent() type of the Agent Framework, edited the test case to correctly test the usage of the data and uri fields while using DataContent()

* Fixed tests related to the changes of the DataContent type, added testing for both string and byte representations
2025-12-17 16:11:39 +00:00
Dmytro StrukandGitHub 3cd805f0bf Added additional arguments for Azure AI agent (#2922) 2025-12-17 08:08:01 +00:00
CopilotGitHubSergeyMenshykhcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
c7ddb8aa14 .NET: Make DelegatingAIAgent abstract (#2797)
* Initial plan

* Make DelegatingAIAgent abstract

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>
2025-12-17 07:31:10 +00:00
Giles OdigweandGitHub d5527982b6 Python: Azure AI Agent with Bing Grounding Citations Sample (#2892)
* bing grounding sample with citations

* small fix

* fix
2025-12-17 00:43:38 +00:00
Dmytro StrukandGitHub ec1c5e9c11 Updated Ollama package version (#2920) 2025-12-17 00:42:27 +00:00
Evan MattsonandGitHub 06cdcb93f0 Fix Pydantic error when using Literal type for tool params (#2893) 2025-12-17 00:27:01 +00:00
Evan MattsonandGitHub 6adcac2e97 Python: Flow custom kwargs to agents via Workflow SharedState (#2894)
* Flow custom kwargs to agents via SharedState

* Address Copilot feedback

* Improve sample typing

* Fix test
2025-12-17 00:04:00 +00:00
Tao ChenandGitHub 8fca71e5ad [BREAKING] Python: Add factory pattern to handoff orchestration builder (#2844)
* WIP: Factory pattern to handoff

* Add factory pattern to concurrent orchestration builder; Next: tests and sample verification

* Add tests and improve comments

* Fix mypy

* Simplify handoff_simple.py

* Simplify handoff_autonoumous.py and bug fix

* Update readme

* Address Copilot comments
2025-12-16 23:38:33 +00:00
Phillip HoffandGitHub 2bde58f915 Python: Switch to new "run" method name. (#2890)
* Switch to `run` method.

* Add support for deprecated `run_agent`.

* Fix entity method name.

* Fix method name and improve tests.

* Update comment.

* Update Python CHANGELOG.
2025-12-16 22:08:12 +00:00
Phillip HoffandGitHub 03a403d2fa .NET: Switch to new "Run" method name. (#2843)
* Switch to new "RunAgent" method name.

* Try to disable false positive naming warning.

* Add comment about disabled warnings.

* Rename `RunAgent` to just `Run`.

* Update CHANGELOG.
2025-12-16 22:07:59 +00:00
Dmytro StrukandGitHub e319707058 Updated package versions (#2913) 2025-12-16 18:51:44 +00:00
Giles OdigweandGitHub 54f482df73 Python: Update Mem0Provider to use v2 search API filters parameter (#2766)
* short fix to move id parameters to filters object

* added tests

* small fix

* mem0 dependency update
2025-12-16 18:23:37 +00:00
Chris GillumandGitHub 754dfb2c9d .NET: Add TTLs to durable agent sessions (#2679)
* .NET: Add TTLs to durable agent sessions

* Remove unnecessary async

* PR feedback: clarify UTC

* PR feedback: limit minimum signal delay to <= 5 minutes

* PR feedback: Fix TTL disablement

* Linter: use auto-property

* Fix build break from OpenAI SDK change

* Updated CHANGELOG.md

* PR feedback

* Reduce default TTL to 14 days to work around DTS bug
2025-12-16 18:11:44 +00:00
Roger BarretoandGitHub b15466f058 .NET: Cosmos DB UT Fast Skip (For Non-Configured Local envs) (#2906)
* Cosmos DB UT Fast Skip (Non-Configured Local envs) + Long running UT skip in pipeline when no CosmosDB changes happened

* Force a CosmosDB source code change to trigger the pipeline

* Address possible string boolean mismatch

* Add debug

* Enabling emulator always when running IT
2025-12-16 17:37:41 +00:00
Roger BarretoandGitHub 3a7047f6e4 Skip failing IT (#2904) 2025-12-16 17:21:22 +00:00
2f06fe557a Python : Ollama Connector for Agent Framework (#1104)
* Initial Commit for Olama Connector

* Added Olama Sample

* Add Sample & Fixed Open Telemetry

* Fixed Spelling from Olama to Ollama

* remove"opentelemetry-semantic-conventions-ai ~=0.4.13" since its handled in a different pr

* Added Tool Calling

* Finalizing test cases

* Adjust samples to be more reliable

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

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

* Update python/packages/ollama/pyproject.toml

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

* Update python/packages/ollama/tests/test_ollama_chat_client.py

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

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

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

* Improved Docstrings & Sample

* Update python/packages/ollama/agent_framework_ollama/_chat_client.py

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

* Integrate PR Feedback
- Divided Streaming and Non-Streaming into independent Methods
- Catch Ollama Validation Error
- Add OTEL Provider Name
- Checked Ollama Messages
- Add Usage Statistics

* Revert setting, so it can be none

* Validate Message formatting between AF and Ollama

* Catch Ollama Error and raise a ServiceResponse Error

* Fix mypy error

* remove .vscode comma

* Add Reasoning support & adjust to new structure

* Add Ollama Multimodality and Reasoning

* Add test cases for reasoning

* Add Tests for Error Handling in Ollama Client

* Update python/samples/getting_started/multimodal_input/ollama_chat_multimodal.py

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

* Integrated Copilot Feedback

* Implement first PR Feedback

* Adjust Readme files for examples

* Adjust argument passing via additional chat options

* Implemented PR Feedback

* Removing Ollama Package from Core and moving samples

* Fix Link & Adding Samples to Main Sample Readme

* Fixing Links in Readme

* Moved Multimodal and Chat Example

* Fixed Link in ChatClient to Ollama

* Fix AgentFramework Links in Ollama Project

* Fix observability breaking change

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2025-12-16 15:02:38 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1dbf3fd5cf Bump actions/upload-artifact from 5 to 6 (#2860)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:39:56 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0132cf65e4 Bump actions/cache from 4 to 5 (#2861)
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:37:15 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a53a3c7af8 Bump actions/download-artifact from 6 to 7 (#2862)
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 13:36:56 +00:00
3c322c91e7 .NET: Update to latest Azure.AI.*, OpenAI, and M.E.AI* (#2850)
* Update to latest Azure.AI.*, OpenAI, and M.E.AI*

Absorb breaking changes in Responses surface area

* Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs

* Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs

* Update dotnet/samples/AgentWebChat/AgentWebChat.AgentHost/Utilities/ChatClientExtensions.cs

* Update dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs

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

* Using patch to remove the model is necessary, updated the response client to actually use the the ForAgent

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2025-12-16 12:41:20 +00:00
Evan MattsonandGitHub 958a488f96 Python: Fix context duplication in handoff workflows when restoring from checkpoint (#2867)
* Fix context duplication in handoff workflows when restoring from checkpoint

* Address Copilot PR review
2025-12-16 09:52:59 +00:00
Evan MattsonandGitHub 11d6dcfe80 Python: Fix middleware terminate flag to exit function calling loop immediately (#2868)
* Fix middleware terminate flag to exit function calling loop immediately

* Eliminating duck typing

* Improve function exec result handling

* Fix race condition

* Fix mypy issues
2025-12-16 09:52:52 +00:00
Eduard van ValkenburgandGitHub 3139347526 Python: [BREAKING] Observability updates (#2782)
* fixes Python: Add env_file_path parameter to setup_observability() similar to AzureOpenAIChatClient
Fixes #2186

* WIP on updates using configure_azure_monitor

* improved setup and clarity

* fixed root .env.example

* revert changes

* updated files

* updated sample

* updated zero code

* test fixes and fixed links

* fix devui

* removed planning docs

* added enable method and updated readme and samples

* clarified docstring

* add return annotation

* updated naming

* update capatilized version

* updated readme and some fixes

* updated decorator name inline with the rest

* feedback from comments addressed
2025-12-16 06:56:30 +00:00
3c379718e9 Python: Use agent description in HandoffBuilder auto-generated tools (#2713) (#2714)
## Summary
Enhanced `HandoffBuilder._apply_auto_tools` to use the target agent's
description when creating handoff tools, providing more informative tool
descriptions for LLMs.

## Changes
- Modified `_apply_auto_tools` to extract `description` from
  `AgentExecutor._agent` when available
- Updated iteration to use `.items()` for more efficient dict traversal
- Handoff tools now use agent descriptions instead of generic placeholders

## Example
Before: "Handoff to the refund_agent agent."
After: "You handle refund requests. Ask for order details and process refunds."

## Testing
- All handoff tests pass (20/20)
- No breaking changes to existing API

Fixes #2713

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2025-12-16 01:31:26 +00:00
Evan MattsonandGitHub a7298757f5 Python: Fix WorkflowAgent to emit yield_output as agent response (#2866)
* Fix WorkflowAgent to emit yield_output as agent response

* use raw_representation

* Raw representation handling
2025-12-16 01:14:26 +00:00
Evan MattsonandGitHub 0dcebc6eae Python: Filter framework kwargs from MCP tool invocations (#2870)
* Filter framework kwargs from MCP tool invocations

* Fixes
2025-12-16 01:10:09 +00:00
Tao ChenandGitHub e0ff153ee9 Python: Remove warnings from workflow builder on not using factories (#2808)
* Revert concurrent

* Fix comments
2025-12-12 07:56:16 +00:00
Richard OrtegaandGitHub e008144187 Update OpenAIResponses.yaml to match AgentSchema (#2598)
1. Update `connection` child types --  `kind: ApiKey` to `kind: key` otherwise schema will fail: https://microsoft.github.io/AgentSchema/reference/apikeyconnection/

2.  Update `outputSchema`'s `PropertySchema` to be `kind` instead of `type` otherwise schema will fail: https://microsoft.github.io/AgentSchema/reference/propertyschema/
2025-12-12 07:55:01 +00:00
Evan MattsonandGitHub 0fc7933a92 Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774) 2025-12-12 04:04:31 +00:00
Dmytro StrukandGitHub d7434d59ce Python: Added custom args and thread object to ai_function kwargs (#2769)
* Added an example of using kwargs in ai_function

* Added thread object to ai_function kwargs

* Updated docs

* Small fix

* Added thread parameter filtering
2025-12-12 01:53:04 +00:00
eb1117fff4 .NET: adds support for labels in edges, fixes rendering of labels in dot a… (#1507)
* adds support for labels in edges,  fixes rendering of labels in dot and mermaid, adds rendering of labels in edges

* Update dotnet/src/Microsoft.Agents.AI.Workflows/Visualization/WorkflowVisualizer.cs

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

* escaping edge labels, adding tests for labels containing strange characters that would break the diagram and enabling the previous signature so the API has backwards compatibility.

* Unify label in EdgeData

* Edge API adjustments, removed useless "sanitizer"

* fixed test

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-12 00:31:45 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
16230d3b20 Bump actions/checkout from 5 to 6 (#2404)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-11 18:43:52 +00:00
Dmytro StrukandGitHub 8d53b20026 Python: Updated package versions (#2784)
* Updated package versions

* Small fix
2025-12-11 18:39:08 +00:00
Eduard van ValkenburgandGitHub c376868ec9 Python: added more complete parsing for mcp tool arguments (#2756)
* added more complete parsing for mcp tool arguments

* fixed mypy

* added nonlocal model counter, and some fixes

* fixes in naming logic

* extracted json parsing function, added parametrized test and checked coverage
2025-12-11 17:24:08 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8bb9927f3c Bump Azure.AI.AgentServer.AgentFramework from 1.0.0-beta.4 to 1.0.0-beta.5 (#2778)
---
updated-dependencies:
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.AI.AgentServer.AgentFramework
  dependency-version: 1.0.0-beta.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 14:08:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
194486c4cc Bump Azure.Identity from 1.17.0 to 1.17.1 (#2780)
---
updated-dependencies:
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Azure.Identity
  dependency-version: 1.17.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 11:04:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0413f4220a Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.4.7 to 4.0.4.11 (#2777)
---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-11 11:01:19 +00:00
CopilotGitHubrogerbarretoCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
67e83042cf .NET: Add Conversation State Sample (Step05) (#2697)
* Initial plan

* Add Agent_OpenAI_Step05_Conversation sample for conversation state management

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Update Program.cs comment to accurately describe the sample

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Update the code to use the ConversationClient more in line with the samples in OpenAI

* Apply suggestions from code review

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

* Changing sample to use ChatClientAgent and conversationId in GetNewThread

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 10:58:42 +00:00
5da1c2fd4c code ql sm04598 (#2723)
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
2025-12-11 10:34:58 +00:00
SergeyMenshykhandGitHub 989b6ebe71 .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)
* prevent nulls in AIAgent property

* address feedback
2025-12-11 09:50:25 +00:00
Evan MattsonandGitHub 3481914981 Capture file IDs from code interpreter in streaming responses (#2741) 2025-12-11 07:30:19 +00:00
KurtandGitHub 4c6a5d4aa1 Python: fix: GroupChat ManagerSelectionResponse JSON Schema for OpenAI Structured Outpu… (#2750)
* fix: ManagerSelectionResponse JSON Schema for OpenAI Structured Output Strict Mode

* refactor: install pre-commit then commit again
2025-12-11 02:34:12 +00:00
Tao ChenandGitHub 191779ce80 Python: Add factory pattern to concurrent orchestration builder (#2738)
* Add factory pattern to concurrent orchestration builder

* Update readme

* Address AI comments

* Fix unit tests

* Fix import

* Prevent multiple calls to set participants or factories

* Add comments

* Mitigate warnings

* Fix mypy

* Address comments

* Address Copilot comments

* Fix tests
2025-12-11 01:23:28 +00:00
225 changed files with 6216 additions and 26555 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://aka.ms/agent-framework
about: Check out the official documentation for guides and API reference.
- name: Discussions
url: https://github.com/microsoft/agent-framework/discussions
about: Ask questions about Agent Framework.
about: Ask questions and share ideas in GitHub Discussions.
-70
View File
@@ -1,70 +0,0 @@
name: .NET Bug Report
description: Report a bug in the Agent Framework .NET SDK
title: ".NET: [Bug]: "
labels: ["bug", ".NET"]
type: bug
body:
- type: textarea
id: description
attributes:
label: Description
description: Please provide a clear and detailed description of the bug.
placeholder: |
- What happened?
- What did you expect to happen?
- Steps to reproduce the issue
validations:
required: true
- type: textarea
id: code-sample
attributes:
label: Code Sample
description: If applicable, provide a minimal code sample that demonstrates the issue.
placeholder: |
```csharp
// Your code here
```
render: markdown
validations:
required: false
- type: textarea
id: error-messages
attributes:
label: Error Messages / Stack Traces
description: Include any error messages or stack traces you received.
placeholder: |
```
Paste error messages or stack traces here
```
render: markdown
validations:
required: false
- type: input
id: dotnet-packages
attributes:
label: Package Versions
description: List the Microsoft.Agents.* packages and versions you are using
placeholder: "e.g., Microsoft.Agents.AI.Abstractions: 1.0.0, Microsoft.Agents.AI.OpenAI: 1.0.0"
validations:
required: true
- type: input
id: dotnet-version
attributes:
label: .NET Version
description: What version of .NET are you using?
placeholder: "e.g., .NET 8.0"
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots that might be helpful.
placeholder: "Any additional information..."
validations:
required: false
@@ -1,51 +0,0 @@
name: Feature Request
description: Request a new feature for Microsoft Agent Framework
title: "[Feature]: "
type: feature
body:
- type: textarea
id: description
attributes:
label: Description
description: Please describe the feature you'd like and why it would be useful.
placeholder: |
Describe the feature you're requesting:
- What problem does it solve?
- What would the expected behavior be?
- Are there any alternatives you've considered?
validations:
required: true
- type: textarea
id: code-sample
attributes:
label: Code Sample
description: If applicable, provide a code sample showing how you'd like to use this feature.
placeholder: |
```python
# Your code here
```
or
```csharp
// Your code here
```
render: markdown
validations:
required: false
- type: dropdown
id: language
attributes:
label: Language/SDK
description: Which language/SDK does this feature apply to?
options:
- Both
- .NET
- Python
- Other / Not Applicable
default: 0
validations:
required: false
+203
View File
@@ -0,0 +1,203 @@
name: Issue Report
description: Report a bug, request a feature, or ask a question about Microsoft Agent Framework
title: "[Issue]: "
labels: ["triage"]
body:
- type: dropdown
id: language
attributes:
label: Language
description: Which language/SDK are you using?
options:
- .NET
- Python
- None / Not Applicable
validations:
required: true
- type: dropdown
id: issue-type
attributes:
label: Type of Issue
description: What type of issue is this?
options:
- Bug
- Feature Request
- Question
validations:
required: true
- type: markdown
attributes:
value: |
## Version Information
Please provide the version of the package(s) you are using. Select the relevant packages below.
- type: markdown
attributes:
value: "### .NET Packages"
- type: input
id: dotnet-agents-ai
attributes:
label: Microsoft.Agents.AI
description: Version of Microsoft.Agents.AI (e.g., 1.0.0)
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-abstractions
attributes:
label: Microsoft.Agents.AI.Abstractions
description: Version of Microsoft.Agents.AI.Abstractions
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-openai
attributes:
label: Microsoft.Agents.AI.OpenAI
description: Version of Microsoft.Agents.AI.OpenAI
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-azureai
attributes:
label: Microsoft.Agents.AI.AzureAI
description: Version of Microsoft.Agents.AI.AzureAI
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-anthropic
attributes:
label: Microsoft.Agents.AI.Anthropic
description: Version of Microsoft.Agents.AI.Anthropic
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-hosting
attributes:
label: Microsoft.Agents.AI.Hosting
description: Version of Microsoft.Agents.AI.Hosting
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-agents-ai-workflows
attributes:
label: Microsoft.Agents.AI.Workflows
description: Version of Microsoft.Agents.AI.Workflows
placeholder: "e.g., 1.0.0"
validations:
required: false
- type: input
id: dotnet-other-packages
attributes:
label: Other .NET Packages
description: List any other Microsoft.Agents.* packages and versions you are using
placeholder: "e.g., Microsoft.Agents.AI.CopilotStudio: 1.0.0, Microsoft.Agents.AI.Purview: 1.0.0"
validations:
required: false
- type: markdown
attributes:
value: "### Python Packages"
- type: input
id: python-core
attributes:
label: agent-framework-core
description: Version of agent-framework-core
placeholder: "e.g., 1.0.0b1"
validations:
required: false
- type: input
id: python-azure-ai
attributes:
label: agent-framework-azure-ai
description: Version of agent-framework-azure-ai
placeholder: "e.g., 1.0.0b1"
validations:
required: false
- type: input
id: python-anthropic
attributes:
label: agent-framework-anthropic
description: Version of agent-framework-anthropic
placeholder: "e.g., 1.0.0b1"
validations:
required: false
- type: input
id: python-azurefunctions
attributes:
label: agent-framework-azurefunctions
description: Version of agent-framework-azurefunctions
placeholder: "e.g., 1.0.0b1"
validations:
required: false
- type: input
id: python-other-packages
attributes:
label: Other Python Packages
description: List any other agent-framework-* packages and versions you are using
placeholder: "e.g., agent-framework-mem0: 1.0.0b1, agent-framework-redis: 1.0.0b1"
validations:
required: false
- type: markdown
attributes:
value: "---"
- type: textarea
id: description
attributes:
label: Description
description: Please provide a clear and detailed description of the issue, feature request, or question.
placeholder: |
For bugs: Describe what happened, what you expected to happen, and steps to reproduce.
For features: Describe the feature you'd like and why it would be useful.
For questions: Describe what you're trying to accomplish.
validations:
required: true
- type: textarea
id: code-sample
attributes:
label: Code Sample
description: If applicable, provide a minimal code sample that demonstrates the issue or your use case.
placeholder: |
```python
# Your code here
```
or
```csharp
// Your code here
```
render: markdown
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context, screenshots, error messages, or stack traces that might be helpful.
placeholder: "Any additional information..."
validations:
required: false
-70
View File
@@ -1,70 +0,0 @@
name: Python Bug Report
description: Report a bug in the Agent Framework Python SDK
title: "Python: [Bug]: "
labels: ["bug", "Python"]
type: bug
body:
- type: textarea
id: description
attributes:
label: Description
description: Please provide a clear and detailed description of the bug.
placeholder: |
- What happened?
- What did you expect to happen?
- Steps to reproduce the issue
validations:
required: true
- type: textarea
id: code-sample
attributes:
label: Code Sample
description: If applicable, provide a minimal code sample that demonstrates the issue.
placeholder: |
```python
# Your code here
```
render: markdown
validations:
required: false
- type: textarea
id: error-messages
attributes:
label: Error Messages / Stack Traces
description: Include any error messages or stack traces you received.
placeholder: |
```
Paste error messages or stack traces here
```
render: markdown
validations:
required: false
- type: input
id: python-packages
attributes:
label: Package Versions
description: List the agent-framework-* packages and versions you are using
placeholder: "e.g., agent-framework-core: 1.0.0, agent-framework-azure-ai: 1.0.0"
validations:
required: true
- type: input
id: python-version
attributes:
label: Python Version
description: What version of Python are you using?
placeholder: "e.g., Python 3.11"
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context or screenshots that might be helpful.
placeholder: "Any additional information..."
validations:
required: false
+5 -6
View File
@@ -208,14 +208,13 @@ WARP.md
**/projectBrief.md
# Azurite storage emulator files
*/__azurite_db_blob__.json*
*/__azurite_db_blob_extent__.json*
*/__azurite_db_queue__.json*
*/__azurite_db_queue_extent__.json*
*/__azurite_db_table__.json*
*/__azurite_db_blob__.json
*/__azurite_db_blob_extent__.json
*/__azurite_db_queue__.json
*/__azurite_db_queue_extent__.json
*/__azurite_db_table__.json
*/__blobstorage__/
*/__queuestorage__/
*/AzuriteConfig
# Azure Functions local settings
local.settings.json
+3 -3
View File
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.0.1" />
<PackageVersion Include="Anthropic" Version="12.0.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.1.0" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
@@ -26,7 +26,7 @@
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<!-- Google Gemini -->
<PackageVersion Include="Google.GenAI" Version="0.9.0" />
<PackageVersion Include="Google.GenAI" Version="0.6.0" />
<PackageVersion Include="Mscc.GenerativeAI.Microsoft" Version="2.9.3" />
<!-- Microsoft.Azure.* -->
<PackageVersion Include="Microsoft.Azure.Cosmos" Version="3.54.0" />
@@ -100,7 +100,7 @@
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260108.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260108.1</PackageVersion>
<GitTag>1.0.0-preview.260108.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251219.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251219.1</PackageVersion>
<GitTag>1.0.0-preview.251219.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -39,8 +39,8 @@
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,7 +9,7 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -25,7 +25,7 @@ AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAd
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
@@ -38,8 +38,8 @@
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -8,8 +8,9 @@ using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Data;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
@@ -27,10 +28,7 @@ AIAgent agent = new AzureOpenAIClient(
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
},
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
});
@@ -38,7 +38,7 @@
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
</ItemGroup>
+7 -15
View File
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI.A2A;
/// Support for tasks will be added later as part of the long-running
/// executions work.
/// </remarks>
public sealed class A2AAgent : AIAgent
internal sealed class A2AAgent : AIAgent
{
private readonly A2AClient _a2aClient;
private readonly string? _id;
@@ -84,13 +84,9 @@ public sealed class A2AAgent : AIAgent
}
else
{
MessageSendParams sendParams = new()
{
Message = CreateA2AMessage(typedThread, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
var a2aMessage = CreateA2AMessage(typedThread, messages);
a2aResponse = await this._a2aClient.SendMessageAsync(sendParams, cancellationToken).ConfigureAwait(false);
a2aResponse = await this._a2aClient.SendMessageAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, this.Name);
@@ -158,13 +154,9 @@ public sealed class A2AAgent : AIAgent
// a2aSseEvents = this._a2aClient.SubscribeToTaskAsync(token.TaskId, cancellationToken).ConfigureAwait(false);
}
MessageSendParams sendParams = new()
{
Message = CreateA2AMessage(typedThread, messages),
Metadata = options?.AdditionalProperties?.ToA2AMetadata()
};
var a2aMessage = CreateA2AMessage(typedThread, messages);
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(sendParams, cancellationToken).ConfigureAwait(false);
a2aSseEvents = this._a2aClient.SendMessageStreamingAsync(new MessageSendParams { Message = a2aMessage }, cancellationToken).ConfigureAwait(false);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunStreamingAsync), this.Id, this.Name);
@@ -206,10 +198,10 @@ public sealed class A2AAgent : AIAgent
protected override string? IdCore => this._id;
/// <inheritdoc/>
public override string? Name => this._name;
public override string? Name => this._name ?? base.Name;
/// <inheritdoc/>
public override string? Description => this._description;
public override string? Description => this._description ?? base.Description;
private A2AAgentThread GetA2AThread(AgentThread? thread, AgentRunOptions? options)
{
@@ -14,9 +14,6 @@ internal static class A2AMetadataExtensions
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is public.
/// </remarks>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Extension methods for AdditionalPropertiesDictionary.
/// </summary>
internal static class AdditionalPropertiesDictionaryExtensions
{
/// <summary>
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is available.
/// </remarks>
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
{
if (additionalProperties is not { Count: > 0 })
{
return null;
}
var metadata = new Dictionary<string, JsonElement>();
foreach (var kvp in additionalProperties)
{
if (kvp.Value is JsonElement)
{
metadata[kvp.Key] = (JsonElement)kvp.Value!;
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
}
}
@@ -291,15 +291,6 @@ public class AgentRunResponse
return updates;
}
/// <summary>
/// Deserializes the response text into the given type.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <returns>The result as the requested type.</returns>
/// <exception cref="InvalidOperationException">The result is not parsable into the requested type.</exception>
public T Deserialize<T>() =>
this.Deserialize<T>(AgentAbstractionsJsonUtilities.DefaultOptions);
/// <summary>
/// Deserializes the response text into the given type using the specified serializer options.
/// </summary>
@@ -320,15 +311,6 @@ public class AgentRunResponse
};
}
/// <summary>
/// Tries to deserialize response text into the given type.
/// </summary>
/// <typeparam name="T">The output type to deserialize into.</typeparam>
/// <param name="structuredOutput">The parsed structured output.</param>
/// <returns><see langword="true" /> if parsing was successful; otherwise, <see langword="false" />.</returns>
public bool TryDeserialize<T>([NotNullWhen(true)] out T? structuredOutput) =>
this.TryDeserialize(AgentAbstractionsJsonUtilities.DefaultOptions, out structuredOutput);
/// <summary>
/// Tries to deserialize response text into the given type using the specified serializer options.
/// </summary>
@@ -43,14 +43,10 @@ public static class AIAgentExtensions
{
var contextId = messageSendParams.Message.ContextId ?? Guid.NewGuid().ToString("N");
var thread = await hostAgent.GetOrCreateThreadAsync(contextId, cancellationToken).ConfigureAwait(false);
var options = messageSendParams.Metadata is not { Count: > 0 }
? null
: new AgentRunOptions { AdditionalProperties = messageSendParams.Metadata.ToAdditionalProperties() };
var response = await hostAgent.RunAsync(
messageSendParams.ToChatMessages(),
thread: thread,
options: options,
cancellationToken: cancellationToken).ConfigureAwait(false);
await hostAgent.SaveThreadAsync(contextId, thread, cancellationToken).ConfigureAwait(false);
@@ -60,8 +56,7 @@ public static class AIAgentExtensions
MessageId = response.ResponseId ?? Guid.NewGuid().ToString("N"),
ContextId = contextId,
Role = MessageRole.Agent,
Parts = parts,
Metadata = response.AdditionalProperties?.ToA2AMetadata()
Parts = parts
};
}
}
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
/// <summary>
/// Extension methods for A2A metadata dictionary.
/// </summary>
internal static class A2AMetadataExtensions
{
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is public.
/// </remarks>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (var kvp in metadata)
{
additionalProperties[kvp.Key] = kvp.Value;
}
return additionalProperties;
}
}
@@ -1,45 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using A2A;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.Converters;
/// <summary>
/// Extension methods for AdditionalPropertiesDictionary.
/// </summary>
internal static class AdditionalPropertiesDictionaryExtensions
{
/// <summary>
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is available.
/// </remarks>
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
{
if (additionalProperties is not { Count: > 0 })
{
return null;
}
var metadata = new Dictionary<string, JsonElement>();
foreach (var kvp in additionalProperties)
{
if (kvp.Value is JsonElement)
{
metadata[kvp.Key] = (JsonElement)kvp.Value!;
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
}
}
@@ -50,7 +50,7 @@ TokenCredential browserCredential = new InteractiveBrowserCredential(
IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetResponsesClient(deploymentName)
.GetOpenAIResponseClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("My Sample App"))
@@ -198,7 +198,7 @@ Use the chat middleware when you attach directly to a chat client (e.g. minimal
IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetResponsesClient(deploymentName)
.GetOpenAIResponseClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
@@ -10,10 +10,4 @@ namespace Microsoft.Agents.AI.Workflows;
/// <param name="e">
/// Optionally, the <see cref="Exception"/> representing the error.
/// </param>
public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e)
{
/// <summary>
/// Gets the exception that caused the current operation to fail, if one occurred.
/// </summary>
public Exception? Exception => this.Data as Exception;
}
public class WorkflowErrorEvent(Exception? e) : WorkflowEvent(e);
@@ -18,12 +18,11 @@ internal sealed class WorkflowHostAgent : AIAgent
private readonly string? _id;
private readonly CheckpointManager? _checkpointManager;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly bool _includeExceptionDetails;
private readonly Task<ProtocolDescriptor> _describeTask;
private readonly ConcurrentDictionary<string, string> _assignedRunIds = [];
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null, bool includeExceptionDetails = false)
public WorkflowHostAgent(Workflow workflow, string? id = null, string? name = null, string? description = null, CheckpointManager? checkpointManager = null, IWorkflowExecutionEnvironment? executionEnvironment = null)
{
this._workflow = Throw.IfNull(workflow);
@@ -31,7 +30,6 @@ internal sealed class WorkflowHostAgent : AIAgent
? InProcessExecution.Concurrent
: InProcessExecution.OffThread);
this._checkpointManager = checkpointManager;
this._includeExceptionDetails = includeExceptionDetails;
this._id = id;
this.Name = name;
@@ -63,10 +61,10 @@ internal sealed class WorkflowHostAgent : AIAgent
protocol.ThrowIfNotChatProtocol();
}
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails);
public override AgentThread GetNewThread() => new WorkflowThread(this._workflow, this.GenerateNewId(), this._executionEnvironment, this._checkpointManager);
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, this._includeExceptionDetails, jsonSerializerOptions);
=> new WorkflowThread(this._workflow, serializedThread, this._executionEnvironment, this._checkpointManager, jsonSerializerOptions);
private ValueTask<WorkflowThread> UpdateThreadAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, CancellationToken cancellationToken = default)
{
@@ -21,8 +21,6 @@ public static class WorkflowHostingExtensions
/// <param name="executionEnvironment">Specify the execution environment to use when running the workflows. See
/// <see cref="InProcessExecution.OffThread"/>, <see cref="InProcessExecution.Concurrent"/> and
/// <see cref="InProcessExecution.Lockstep"/> for the in-process environments.</param>
/// <param name="includeExceptionDetails">If <see langword="true"/>, will include <see cref="System.Exception.Message"/>
/// in the <see cref="ErrorContent"/> representing the workflow error.</param>
/// <returns></returns>
public static AIAgent AsAgent(
this Workflow workflow,
@@ -30,10 +28,9 @@ public static class WorkflowHostingExtensions
string? name = null,
string? description = null,
CheckpointManager? checkpointManager = null,
IWorkflowExecutionEnvironment? executionEnvironment = null,
bool includeExceptionDetails = false)
IWorkflowExecutionEnvironment? executionEnvironment = null)
{
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment, includeExceptionDetails);
return new WorkflowHostAgent(workflow, id, name, description, checkpointManager, executionEnvironment);
}
internal static FunctionCallContent ToFunctionCall(this ExternalRequest request)
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
@@ -18,16 +17,14 @@ internal sealed class WorkflowThread : AgentThread
{
private readonly Workflow _workflow;
private readonly IWorkflowExecutionEnvironment _executionEnvironment;
private readonly bool _includeExceptionDetails;
private readonly CheckpointManager _checkpointManager;
private readonly InMemoryCheckpointManager? _inMemoryCheckpointManager;
public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false)
public WorkflowThread(Workflow workflow, string runId, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
this._includeExceptionDetails = includeExceptionDetails;
// If the user provided an external checkpoint manager, use that, otherwise rely on an in-memory one.
// TODO: Implement persist-only-last functionality for in-memory checkpoint manager, to avoid unbounded
@@ -38,7 +35,7 @@ internal sealed class WorkflowThread : AgentThread
this.MessageStore = new WorkflowMessageStore();
}
public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, bool includeExceptionDetails = false, JsonSerializerOptions? jsonSerializerOptions = null)
public WorkflowThread(Workflow workflow, JsonElement serializedThread, IWorkflowExecutionEnvironment executionEnvironment, CheckpointManager? checkpointManager = null, JsonSerializerOptions? jsonSerializerOptions = null)
{
this._workflow = Throw.IfNull(workflow);
this._executionEnvironment = Throw.IfNull(executionEnvironment);
@@ -83,7 +80,7 @@ internal sealed class WorkflowThread : AgentThread
return marshaller.Marshal(info);
}
public AgentRunResponseUpdate CreateUpdate(string responseId, object raw, params AIContent[] parts)
public AgentRunResponseUpdate CreateUpdate(string responseId, params AIContent[] parts)
{
Throw.IfNullOrEmpty(parts);
@@ -92,8 +89,7 @@ internal sealed class WorkflowThread : AgentThread
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Assistant,
ResponseId = responseId,
RawRepresentation = raw
ResponseId = responseId
};
this.MessageStore.AddMessages(update.ToChatMessage());
@@ -157,29 +153,10 @@ internal sealed class WorkflowThread : AgentThread
case RequestInfoEvent requestInfo:
FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
AgentRunResponseUpdate update = this.CreateUpdate(this.LastResponseId, fcContent);
yield return update;
break;
case WorkflowErrorEvent workflowError:
Exception? exception = workflowError.Exception;
if (exception is TargetInvocationException tie && tie.InnerException != null)
{
exception = tie.InnerException;
}
if (exception != null)
{
string message = this._includeExceptionDetails
? exception.Message
: "An error occurred while executing the workflow.";
ErrorContent errorContent = new(message);
yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
}
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
@@ -162,10 +162,7 @@ public sealed partial class ChatClientAgent : AIAgent
static AgentRunResponse CreateResponse(ChatResponse chatResponse)
{
return new AgentRunResponse(chatResponse)
{
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
};
return new AgentRunResponse(chatResponse);
}
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, thread, options, cancellationToken);
@@ -204,14 +201,11 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread,
ChatOptions? chatOptions,
List<ChatMessage> inputMessagesForChatClient,
IList<ChatMessage>? aiContextProviderMessages,
IList<ChatMessage>? chatMessageStoreMessages,
ChatClientAgentContinuationToken? continuationToken) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
ValidateStreamResumptionAllowed(chatOptions?.ContinuationToken, safeThread);
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
@@ -220,7 +214,7 @@ public sealed partial class ChatClientAgent : AIAgent
this._logger.LogAgentChatClientInvokingAgent(nameof(RunStreamingAsync), this.Id, loggingAgentName, this._chatClientType);
List<ChatResponseUpdate> responseUpdates = GetResponseUpdates(continuationToken);
List<ChatResponseUpdate> responseUpdates = [];
IAsyncEnumerator<ChatResponseUpdate> responseUpdatesEnumerator;
@@ -231,8 +225,8 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -246,8 +240,8 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -259,12 +253,7 @@ public sealed partial class ChatClientAgent : AIAgent
update.AuthorName ??= this.Name;
responseUpdates.Add(update);
yield return new(update)
{
AgentId = this.Id,
ContinuationToken = WrapContinuationToken(update.ContinuationToken, GetInputMessages(inputMessages, continuationToken), responseUpdates)
};
yield return new(update) { AgentId = this.Id };
}
try
@@ -273,8 +262,8 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyMessageStoreOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyMessageStoreOfFailureAsync(safeThread, ex, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
}
@@ -286,10 +275,10 @@ public sealed partial class ChatClientAgent : AIAgent
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await NotifyMessageStoreOfNewMessagesAsync(safeThread, GetInputMessages(inputMessages, continuationToken), chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
await NotifyMessageStoreOfNewMessagesAsync(safeThread, inputMessages, chatMessageStoreMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, GetInputMessages(inputMessages, continuationToken), aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -393,12 +382,7 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread,
ChatOptions? chatOptions,
List<ChatMessage> inputMessagesForChatClient,
IList<ChatMessage>? aiContextProviderMessages,
IList<ChatMessage>? chatMessageStoreMessages,
ChatClientAgentContinuationToken? _) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages, IList<ChatMessage>? chatMessageStoreMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
@@ -490,20 +474,20 @@ public sealed partial class ChatClientAgent : AIAgent
/// <param name="runOptions">Optional run options that may include specific chat configuration settings.</param>
/// <returns>A <see cref="ChatOptions"/> object representing the merged chat configuration, or <see langword="null"/> if
/// neither the run options nor the agent's chat options are available.</returns>
private (ChatOptions?, ChatClientAgentContinuationToken?) CreateConfiguredChatOptions(AgentRunOptions? runOptions)
private ChatOptions? CreateConfiguredChatOptions(AgentRunOptions? runOptions)
{
ChatOptions? requestChatOptions = (runOptions as ChatClientAgentRunOptions)?.ChatOptions?.Clone();
// If no agent chat options were provided, return the request chat options as is.
if (this._agentOptions?.ChatOptions is null)
{
return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
}
// If no request chat options were provided, use the agent's chat options clone.
if (requestChatOptions is null)
{
return GetContinuationTokenAndApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
return ApplyBackgroundResponsesProperties(this._agentOptions?.ChatOptions.Clone(), runOptions);
}
// If both are present, we need to merge them.
@@ -599,26 +583,19 @@ public sealed partial class ChatClientAgent : AIAgent
}
}
return GetContinuationTokenAndApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
return ApplyBackgroundResponsesProperties(requestChatOptions, runOptions);
static (ChatOptions?, ChatClientAgentContinuationToken?) GetContinuationTokenAndApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
static ChatOptions? ApplyBackgroundResponsesProperties(ChatOptions? chatOptions, AgentRunOptions? agentRunOptions)
{
if (agentRunOptions?.AllowBackgroundResponses is not null)
// If any of the background response properties are set in the run options, we should apply both to the chat options.
if (agentRunOptions?.AllowBackgroundResponses is not null || agentRunOptions?.ContinuationToken is not null)
{
chatOptions ??= new ChatOptions();
chatOptions.AllowBackgroundResponses = agentRunOptions.AllowBackgroundResponses;
chatOptions.ContinuationToken = agentRunOptions.ContinuationToken;
}
ChatClientAgentContinuationToken? agentContinuationToken = null;
if ((agentRunOptions?.ContinuationToken ?? chatOptions?.ContinuationToken) is { } continuationToken)
{
agentContinuationToken = ChatClientAgentContinuationToken.FromToken(continuationToken);
chatOptions ??= new ChatOptions();
chatOptions.ContinuationToken = agentContinuationToken!.InnerToken;
}
return (chatOptions, agentContinuationToken);
return chatOptions;
}
}
@@ -629,22 +606,21 @@ public sealed partial class ChatClientAgent : AIAgent
/// <param name="inputMessages">The input messages to use.</param>
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A tuple containing the thread, chat options, messages and continuation token.</returns>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task
<(
ChatClientAgentThread AgentThread,
ChatOptions? ChatOptions,
List<ChatMessage> InputMessagesForChatClient,
IList<ChatMessage>? AIContextProviderMessages,
IList<ChatMessage>? ChatMessageStoreMessages,
ChatClientAgentContinuationToken? ContinuationToken
IList<ChatMessage>? ChatMessageStoreMessages
)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable<ChatMessage> inputMessages,
AgentRunOptions? runOptions,
CancellationToken cancellationToken)
{
(ChatOptions? chatOptions, ChatClientAgentContinuationToken? continuationToken) = this.CreateConfiguredChatOptions(runOptions);
ChatOptions? chatOptions = this.CreateConfiguredChatOptions(runOptions);
// Supplying a thread for background responses is required to prevent inconsistent experience
// for callers if they forget to provide the thread for initial or follow-up runs.
@@ -665,6 +641,11 @@ public sealed partial class ChatClientAgent : AIAgent
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
}
if (chatOptions?.ContinuationToken is not null && typedThread.ConversationId is null && typedThread.MessageStore is null)
{
throw new InvalidOperationException("Continuation tokens are not allowed to be used for initial runs.");
}
List<ChatMessage> inputMessagesForChatClient = [];
IList<ChatMessage>? aiContextProviderMessages = null;
IList<ChatMessage>? chatMessageStoreMessages = null;
@@ -732,7 +713,7 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedThread.ConversationId;
}
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages, continuationToken);
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages, chatMessageStoreMessages);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
@@ -810,43 +791,26 @@ public sealed partial class ChatClientAgent : AIAgent
return Task.CompletedTask;
}
private static ChatClientAgentContinuationToken? WrapContinuationToken(ResponseContinuationToken? continuationToken, IEnumerable<ChatMessage>? inputMessages = null, List<ChatResponseUpdate>? responseUpdates = null)
private static void ValidateStreamResumptionAllowed(ResponseContinuationToken? continuationToken, ChatClientAgentThread safeThread)
{
if (continuationToken is null)
{
return null;
return;
}
return new(continuationToken)
// Streaming resumption is only supported with chat history managed by the agent service because, currently, there's no good solution
// to collect updates received in failed runs and pass them to the last successful run so it can store them to the message store.
if (safeThread.ConversationId is null)
{
// Save input messages to the continuation token so they can be added to the thread and
// provided to the context provider in the last successful streaming resumption run.
// That's necessary for scenarios where initial streaming run is interrupted and streaming is resumed later.
InputMessages = inputMessages?.Any() is true ? inputMessages : null,
// Save all updates received so far to the continuation token so they can be provided to the
// message store and context provider in the last successful streaming resumption run.
// That's necessary for scenarios where a streaming run is interrupted after some updates were received.
ResponseUpdates = responseUpdates?.Count > 0 ? responseUpdates : null
};
}
private static IEnumerable<ChatMessage> GetInputMessages(IReadOnlyCollection<ChatMessage> inputMessages, ChatClientAgentContinuationToken? token)
{
// First, use input messages if provided.
if (inputMessages.Count > 0)
{
return inputMessages;
throw new NotSupportedException("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.");
}
// Fallback to messages saved in the continuation token if available.
return token?.InputMessages ?? [];
}
private static List<ChatResponseUpdate> GetResponseUpdates(ChatClientAgentContinuationToken? token)
{
// Restore any previously received updates from the continuation token.
return token?.ResponseUpdates?.ToList() ?? [];
// Similarly, streaming resumption is not supported when a context provider is used because, currently, there's no good solution
// to collect updates received in failed runs and pass them to the last successful run so it can notify the context provider of the updates.
if (safeThread.AIContextProvider is not null)
{
throw new NotSupportedException("Using context provider with streaming resumption is not supported.");
}
}
private string GetLoggingAgentName() => this.Name ?? "UnnamedAgent";
@@ -1,170 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// Represents a continuation token for ChatClientAgent operations.
/// </summary>
internal class ChatClientAgentContinuationToken : ResponseContinuationToken
{
private const string TokenTypeName = "chatClientAgentContinuationToken";
private const string TypeDiscriminator = "type";
/// <summary>
/// Initializes a new instance of the <see cref="ChatClientAgentContinuationToken"/> class.
/// </summary>
/// <param name="innerToken">A continuation token provided by the underlying <see cref="IChatClient"/>.</param>
[JsonConstructor]
internal ChatClientAgentContinuationToken(ResponseContinuationToken innerToken)
{
this.InnerToken = innerToken;
}
public override ReadOnlyMemory<byte> ToBytes()
{
using MemoryStream stream = new();
using Utf8JsonWriter writer = new(stream);
writer.WriteStartObject();
// This property should be the first one written to identify the type during deserialization.
writer.WriteString(TypeDiscriminator, TokenTypeName);
writer.WriteString("innerToken", JsonSerializer.Serialize(this.InnerToken, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken))));
if (this.InputMessages?.Any() is true)
{
writer.WriteString("inputMessages", JsonSerializer.Serialize(this.InputMessages, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>))));
}
if (this.ResponseUpdates?.Count > 0)
{
writer.WriteString("responseUpdates", JsonSerializer.Serialize(this.ResponseUpdates, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList<ChatResponseUpdate>))));
}
writer.WriteEndObject();
writer.Flush();
return stream.ToArray();
}
/// <summary>
/// Create a new instance of <see cref="ChatClientAgentContinuationToken"/> from the provided <paramref name="token"/>.
/// </summary>
/// <param name="token">The token to create the <see cref="ChatClientAgentContinuationToken"/> from.</param>
/// <returns>A <see cref="ChatClientAgentContinuationToken"/> equivalent of the provided <paramref name="token"/>.</returns>
internal static ChatClientAgentContinuationToken FromToken(ResponseContinuationToken token)
{
if (token is ChatClientAgentContinuationToken chatClientContinuationToken)
{
return chatClientContinuationToken;
}
ReadOnlyMemory<byte> data = token.ToBytes();
if (data.Length == 0)
{
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain any data.");
}
Utf8JsonReader reader = new(data.Span);
// Move to the start object token.
_ = reader.Read();
// Validate that the token is of this type.
ValidateTokenType(reader, token);
ResponseContinuationToken? innerToken = null;
IEnumerable<ChatMessage>? inputMessages = null;
IReadOnlyList<ChatResponseUpdate>? responseUpdates = null;
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
{
break;
}
if (reader.TokenType != JsonTokenType.PropertyName)
{
continue;
}
switch (reader.GetString())
{
case "innerToken":
_ = reader.Read();
var innerTokenJson = reader.GetString() ?? throw new ArgumentException("No content for innerToken property.", nameof(token));
innerToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(innerTokenJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
break;
case "inputMessages":
_ = reader.Read();
var innerMessagesJson = reader.GetString() ?? throw new ArgumentException("No content for inputMessages property.", nameof(token));
inputMessages = (IEnumerable<ChatMessage>?)JsonSerializer.Deserialize(innerMessagesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IEnumerable<ChatMessage>)));
break;
case "responseUpdates":
_ = reader.Read();
var responseUpdatesJson = reader.GetString() ?? throw new ArgumentException("No content for responseUpdates property.", nameof(token));
responseUpdates = (IReadOnlyList<ChatResponseUpdate>?)JsonSerializer.Deserialize(responseUpdatesJson, AgentJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IReadOnlyList<ChatResponseUpdate>)));
break;
default:
break;
}
}
if (innerToken is null)
{
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it does not contain an inner token.");
}
return new ChatClientAgentContinuationToken(innerToken)
{
InputMessages = inputMessages,
ResponseUpdates = responseUpdates
};
}
private static void ValidateTokenType(Utf8JsonReader reader, ResponseContinuationToken token)
{
try
{
// Move to the first property.
_ = reader.Read();
// If the first property name is not "type", or its value does not match this token type name, then we know its not this token type.
if (reader.GetString() != TypeDiscriminator || !reader.Read() || reader.GetString() != TokenTypeName)
{
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it is not of the correct type.");
}
}
catch (JsonException ex)
{
Throw.ArgumentException(nameof(token), "Failed to create ChatClientAgentContinuationToken from provided token because it could not be parsed.", ex);
}
}
/// <summary>
/// Gets a continuation token provided by the underlying <see cref="IChatClient"/>.
/// </summary>
internal ResponseContinuationToken InnerToken { get; }
/// <summary>
/// Gets or sets the input messages used for streaming run.
/// </summary>
internal IEnumerable<ChatMessage>? InputMessages { get; set; }
/// <summary>
/// Gets or sets the response updates received so far.
/// </summary>
internal IReadOnlyList<ChatResponseUpdate>? ResponseUpdates { get; set; }
}
@@ -1,253 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
/// <summary>
/// Provides extension methods for <see cref="ChatClientAgent"/> to enable discoverability of <see cref="ChatClientAgentRunOptions"/>.
/// </summary>
public partial class ChatClientAgent
{
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread.
/// </summary>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
public Task<AgentRunResponse> RunAsync(
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunAsync(thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user.
/// </summary>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
public Task<AgentRunResponse> RunAsync(
string message,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a single chat message.
/// </summary>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
public Task<AgentRunResponse> RunAsync(
ChatMessage message,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
public Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent in streaming mode without providing new input messages, relying on existing context and instructions.
/// </summary>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync(thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent in streaming mode with a text message from the user.
/// </summary>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
string message,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent in streaming mode with a single chat message.
/// </summary>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
ChatMessage message,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync(message, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Runs the agent in streaming mode with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response updates generated during invocation.
/// </param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread,
ChatClientAgentRunOptions? options,
CancellationToken cancellationToken = default) =>
this.RunStreamingAsync(messages, thread, (AgentRunOptions?)options, cancellationToken);
/// <summary>
/// Run the agent with no message assuming that all required instructions are already provided to the agent or on the thread, and requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
AgentThread? thread,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
/// <summary>
/// Runs the agent with a text message from the user, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="message">The user message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
string message,
AgentThread? thread,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
/// <summary>
/// Runs the agent with a single chat message, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="message">The chat message to send to the agent.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input message and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
ChatMessage message,
AgentThread? thread,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(message, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
/// <summary>
/// Runs the agent with a collection of chat messages, requesting a response of the specified type <typeparamref name="T"/>.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="serializerOptions">The JSON serialization options to use.</param>
/// <param name="options">Configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="useJsonSchemaResponseFormat">
/// <see langword="true" /> to set a JSON schema on the <see cref="ChatResponseFormat"/>; otherwise, <see langword="false" />. The default is <see langword="true" />.
/// Using a JSON schema improves reliability if the underlying model supports native structured output with a schema, but might cause an error if the model does not support it.
/// </param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="ChatClientAgentRunResponse{T}"/> with the agent's output.</returns>
public Task<ChatClientAgentRunResponse<T>> RunAsync<T>(
IEnumerable<ChatMessage> messages,
AgentThread? thread,
JsonSerializerOptions? serializerOptions,
ChatClientAgentRunOptions? options,
bool? useJsonSchemaResponseFormat = null,
CancellationToken cancellationToken = default) =>
this.RunAsync<T>(messages, thread, serializerOptions, (AgentRunOptions?)options, useJsonSchemaResponseFormat, cancellationToken);
}
@@ -80,7 +80,7 @@ public sealed class ChatClientAgentOptions
/// <summary>
/// Context object passed to the <see cref="AIContextProviderFactory"/> to create a new instance of <see cref="AIContextProvider"/>.
/// </summary>
public sealed class AIContextProviderFactoryContext
public class AIContextProviderFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the <see cref="AIContextProvider"/>, if any.
@@ -97,7 +97,7 @@ public sealed class ChatClientAgentOptions
/// <summary>
/// Context object passed to the <see cref="ChatMessageStoreFactory"/> to create a new instance of <see cref="ChatMessageStore"/>.
/// </summary>
public sealed class ChatMessageStoreFactoryContext
public class ChatMessageStoreFactoryContext
{
/// <summary>
/// Gets or sets the serialized state of the chat message store, if any.
@@ -40,6 +40,7 @@ public sealed class ChatClientAgentRunResponse<T> : AgentRunResponse<T>
/// </summary>
/// <remarks>
/// If the response did not contain JSON, or if deserialization fails, this property will throw.
/// To avoid exceptions, use <see cref="AgentRunResponse.TryDeserialize{T}"/> instead.
/// </remarks>
public override T Result => this._response.Result;
}
@@ -154,10 +154,7 @@ public sealed partial class ChatClientAgent
static ChatClientAgentRunResponse<T> CreateResponse(ChatResponse<T> chatResponse)
{
return new ChatClientAgentRunResponse<T>(chatResponse)
{
ContinuationToken = WrapContinuationToken(chatResponse.ContinuationToken)
};
return new ChatClientAgentRunResponse<T>(chatResponse);
}
return this.RunCoreAsync(GetResponseAsync, CreateResponse, messages, thread, options, cancellationToken);
@@ -832,174 +832,6 @@ public sealed class A2AAgentTests : IDisposable
});
}
[Fact]
public async Task RunAsync_WithAgentMessageResponseMetadata_ReturnsMetadataAsAdditionalPropertiesAsync()
{
// Arrange
this._handler.ResponseToReturn = new AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response with metadata" }],
Metadata = new Dictionary<string, JsonElement>
{
{ "responseKey1", JsonSerializer.SerializeToElement("responseValue1") },
{ "responseCount", JsonSerializer.SerializeToElement(99) }
}
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
// Act
var result = await this._agent.RunAsync(inputMessages);
// Assert
Assert.NotNull(result.AdditionalProperties);
Assert.NotNull(result.AdditionalProperties["responseKey1"]);
Assert.Equal("responseValue1", ((JsonElement)result.AdditionalProperties["responseKey1"]!).GetString());
Assert.NotNull(result.AdditionalProperties["responseCount"]);
Assert.Equal(99, ((JsonElement)result.AdditionalProperties["responseCount"]!).GetInt32());
}
[Fact]
public async Task RunAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
{
// Arrange
this._handler.ResponseToReturn = new AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }]
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var options = new AgentRunOptions
{
AdditionalProperties = new()
{
{ "key1", "value1" },
{ "key2", 42 },
{ "key3", true }
}
};
// Act
await this._agent.RunAsync(inputMessages, null, options);
// Assert
Assert.NotNull(this._handler.CapturedMessageSendParams);
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
Assert.Equal("value1", this._handler.CapturedMessageSendParams.Metadata["key1"].GetString());
Assert.Equal(42, this._handler.CapturedMessageSendParams.Metadata["key2"].GetInt32());
Assert.True(this._handler.CapturedMessageSendParams.Metadata["key3"].GetBoolean());
}
[Fact]
public async Task RunAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
{
// Arrange
this._handler.ResponseToReturn = new AgentMessage
{
MessageId = "response-123",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Response" }]
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test message")
};
var options = new AgentRunOptions
{
AdditionalProperties = null
};
// Act
await this._agent.RunAsync(inputMessages, null, options);
// Assert
Assert.NotNull(this._handler.CapturedMessageSendParams);
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
}
[Fact]
public async Task RunStreamingAsync_WithAdditionalProperties_PropagatesThemAsMetadataToMessageSendParamsAsync()
{
// Arrange
this._handler.StreamingResponseToReturn = new AgentMessage
{
MessageId = "stream-123",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Streaming response" }]
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test streaming message")
};
var options = new AgentRunOptions
{
AdditionalProperties = new()
{
{ "streamKey1", "streamValue1" },
{ "streamKey2", 100 },
{ "streamKey3", false }
}
};
// Act
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
{
}
// Assert
Assert.NotNull(this._handler.CapturedMessageSendParams);
Assert.NotNull(this._handler.CapturedMessageSendParams.Metadata);
Assert.Equal("streamValue1", this._handler.CapturedMessageSendParams.Metadata["streamKey1"].GetString());
Assert.Equal(100, this._handler.CapturedMessageSendParams.Metadata["streamKey2"].GetInt32());
Assert.False(this._handler.CapturedMessageSendParams.Metadata["streamKey3"].GetBoolean());
}
[Fact]
public async Task RunStreamingAsync_WithNullAdditionalProperties_DoesNotSetMetadataAsync()
{
// Arrange
this._handler.StreamingResponseToReturn = new AgentMessage
{
MessageId = "stream-123",
Role = MessageRole.Agent,
Parts = [new TextPart { Text = "Streaming response" }]
};
var inputMessages = new List<ChatMessage>
{
new(ChatRole.User, "Test streaming message")
};
var options = new AgentRunOptions
{
AdditionalProperties = null
};
// Act
await foreach (var _ in this._agent.RunStreamingAsync(inputMessages, null, options))
{
}
// Assert
Assert.NotNull(this._handler.CapturedMessageSendParams);
Assert.Null(this._handler.CapturedMessageSendParams.Metadata);
}
[Fact]
public async Task RunAsync_WithInvalidThreadType_ThrowsInvalidOperationExceptionAsync()
{
@@ -1,186 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
/// </summary>
public sealed class AdditionalPropertiesDictionaryExtensionsTests
{
[Fact]
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary? additionalProperties = null;
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = [];
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
}
[Fact]
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "numberKey", 42 }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
}
[Fact]
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" },
{ "numberKey", 42 },
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
{
// Arrange
int[] arrayValue = [1, 2, 3];
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "arrayKey", arrayValue }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("arrayKey"));
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
Assert.Equal(3, result["arrayKey"].GetArrayLength());
}
[Fact]
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "nullKey", null! }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("nullKey"));
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
}
[Fact]
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
{
// Arrange
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "jsonElementKey", jsonElement }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("jsonElementKey"));
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
}
}
@@ -57,7 +57,7 @@ public class AgentRunResponseTests
RawRepresentation = new object(),
ResponseId = "responseId",
Usage = new UsageDetails(),
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })
ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }),
};
AgentRunResponse response = new(chatResponse);
@@ -214,12 +214,6 @@ public class AgentRunResponseTests
Assert.Equal(100, usageContent.Details.TotalTokenCount);
}
#if NETFRAMEWORK
/// <summary>
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
/// serialization is available.
/// </summary>
[Fact]
public void ParseAsStructuredOutputSuccess()
{
@@ -227,24 +221,6 @@ public class AgentRunResponseTests
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
var animal = response.Deserialize<Animal>();
// Assert.
Assert.NotNull(animal);
Assert.Equal(expectedResult.Id, animal.Id);
Assert.Equal(expectedResult.FullName, animal.FullName);
Assert.Equal(expectedResult.Species, animal.Species);
}
#endif
[Fact]
public void ParseAsStructuredOutputWithJSOSuccess()
{
// Arrange.
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
var animal = response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options);
@@ -286,12 +262,6 @@ public class AgentRunResponseTests
Assert.Throws<JsonException>(() => response.Deserialize<Animal>(TestJsonSerializerContext.Default.Options));
}
#if NETFRAMEWORK
/// <summary>
/// Since Json Serialization using reflection is disabled in .net core builds, and we are using a custom type here that wouldn't
/// be registered with the default source generated serializer, this test will only pass in .net framework builds where reflection-based
/// serialization is available.
/// </summary>
[Fact]
public void TryParseAsStructuredOutputSuccess()
{
@@ -299,24 +269,6 @@ public class AgentRunResponseTests
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
response.TryDeserialize(out Animal? animal);
// Assert.
Assert.NotNull(animal);
Assert.Equal(expectedResult.Id, animal.Id);
Assert.Equal(expectedResult.FullName, animal.FullName);
Assert.Equal(expectedResult.Species, animal.Species);
}
#endif
[Fact]
public void TryParseAsStructuredOutputWithJSOSuccess()
{
// Arrange.
var expectedResult = new Animal { Id = 1, FullName = "Tigger", Species = Species.Tiger };
var response = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, JsonSerializer.Serialize(expectedResult, TestJsonSerializerContext.Default.Animal)));
// Act.
response.TryDeserialize(TestJsonSerializerContext.Default.Options, out Animal? animal);
@@ -1,218 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using A2A;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
/// <summary>
/// Unit tests for the <see cref="AIAgentExtensions"/> class.
/// </summary>
public sealed class AIAgentExtensionsTests
{
/// <summary>
/// Verifies that when messageSendParams.Metadata is null, the options passed to RunAsync are null.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsNull_PassesNullOptionsToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = null
});
// Assert
Assert.Null(capturedOptions);
}
/// <summary>
/// Verifies that when messageSendParams.Metadata has values, the options.AdditionalProperties contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataHasValues_PassesOptionsWithAdditionalPropertiesToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = new Dictionary<string, JsonElement>
{
["key1"] = JsonSerializer.SerializeToElement("value1"),
["key2"] = JsonSerializer.SerializeToElement(42)
}
});
// Assert
Assert.NotNull(capturedOptions);
Assert.NotNull(capturedOptions.AdditionalProperties);
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key1"));
Assert.True(capturedOptions.AdditionalProperties.ContainsKey("key2"));
}
/// <summary>
/// Verifies that when messageSendParams.Metadata is an empty dictionary, the options passed to RunAsync is null
/// because the ToAdditionalProperties extension method returns null for empty dictionaries.
/// </summary>
[Fact]
public async Task MapA2A_WhenMetadataIsEmptyDictionary_PassesNullOptionsToRunAsync()
{
// Arrange
AgentRunOptions? capturedOptions = null;
ITaskManager taskManager = CreateAgentMock(options => capturedOptions = options).Object.MapA2A();
// Act
await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] },
Metadata = []
});
// Assert
Assert.Null(capturedOptions);
}
/// <summary>
/// Verifies that when the agent response has AdditionalProperties, the returned AgentMessage.Metadata contains the converted values.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasAdditionalProperties_ReturnsAgentMessageWithMetadataAsync()
{
// Arrange
AdditionalPropertiesDictionary additionalProps = new()
{
["responseKey1"] = "responseValue1",
["responseKey2"] = 123
};
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = additionalProps
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.NotNull(agentMessage.Metadata);
Assert.Equal(2, agentMessage.Metadata.Count);
Assert.True(agentMessage.Metadata.ContainsKey("responseKey1"));
Assert.True(agentMessage.Metadata.ContainsKey("responseKey2"));
Assert.Equal("responseValue1", agentMessage.Metadata["responseKey1"].GetString());
Assert.Equal(123, agentMessage.Metadata["responseKey2"].GetInt32());
}
/// <summary>
/// Verifies that when the agent response has null AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasNullAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = null
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
/// <summary>
/// Verifies that when the agent response has empty AdditionalProperties, the returned AgentMessage.Metadata is null.
/// </summary>
[Fact]
public async Task MapA2A_WhenResponseHasEmptyAdditionalProperties_ReturnsAgentMessageWithNullMetadataAsync()
{
// Arrange
AgentRunResponse response = new([new ChatMessage(ChatRole.Assistant, "Test response")])
{
AdditionalProperties = []
};
ITaskManager taskManager = CreateAgentMockWithResponse(response).Object.MapA2A();
// Act
A2AResponse a2aResponse = await InvokeOnMessageReceivedAsync(taskManager, new MessageSendParams
{
Message = new AgentMessage { MessageId = "test-id", Role = MessageRole.User, Parts = [new TextPart { Text = "Hello" }] }
});
// Assert
AgentMessage agentMessage = Assert.IsType<AgentMessage>(a2aResponse);
Assert.Null(agentMessage.Metadata);
}
private static Mock<AIAgent> CreateAgentMock(Action<AgentRunOptions?> optionsCallback)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
agentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Callback<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken>(
(_, _, options, _) => optionsCallback(options))
.ReturnsAsync(new AgentRunResponse([new ChatMessage(ChatRole.Assistant, "Test response")]));
return agentMock;
}
private static Mock<AIAgent> CreateAgentMockWithResponse(AgentRunResponse response)
{
Mock<AIAgent> agentMock = new() { CallBase = true };
agentMock.SetupGet(x => x.Name).Returns("TestAgent");
agentMock.Setup(x => x.GetNewThread()).Returns(new TestAgentThread());
agentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(response);
return agentMock;
}
private static async Task<A2AResponse> InvokeOnMessageReceivedAsync(ITaskManager taskManager, MessageSendParams messageSendParams)
{
Func<MessageSendParams, CancellationToken, Task<A2AResponse>>? handler = taskManager.OnMessageReceived;
Assert.NotNull(handler);
return await handler.Invoke(messageSendParams, CancellationToken.None);
}
private sealed class TestAgentThread : AgentThread;
}
@@ -1,187 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI.Hosting.A2A.Converters;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests.Converters;
/// <summary>
/// Unit tests for the <see cref="AdditionalPropertiesDictionaryExtensions"/> class.
/// </summary>
public sealed class AdditionalPropertiesDictionaryExtensionsTests
{
[Fact]
public void ToA2AMetadata_WithNullAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary? additionalProperties = null;
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithEmptyAdditionalProperties_ReturnsNull()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = [];
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.Null(result);
}
[Fact]
public void ToA2AMetadata_WithStringValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
}
[Fact]
public void ToA2AMetadata_WithNumericValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "numberKey", 42 }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
}
[Fact]
public void ToA2AMetadata_WithBooleanValue_ReturnsMetadataWithJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithMultipleProperties_ReturnsMetadataWithAllProperties()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "stringKey", "stringValue" },
{ "numberKey", 42 },
{ "booleanKey", true }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Equal(3, result.Count);
Assert.True(result.ContainsKey("stringKey"));
Assert.Equal("stringValue", result["stringKey"].GetString());
Assert.True(result.ContainsKey("numberKey"));
Assert.Equal(42, result["numberKey"].GetInt32());
Assert.True(result.ContainsKey("booleanKey"));
Assert.True(result["booleanKey"].GetBoolean());
}
[Fact]
public void ToA2AMetadata_WithArrayValue_ReturnsMetadataWithJsonElement()
{
// Arrange
int[] arrayValue = [1, 2, 3];
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "arrayKey", arrayValue }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("arrayKey"));
Assert.Equal(JsonValueKind.Array, result["arrayKey"].ValueKind);
Assert.Equal(3, result["arrayKey"].GetArrayLength());
}
[Fact]
public void ToA2AMetadata_WithNullValue_ReturnsMetadataWithNullJsonElement()
{
// Arrange
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "nullKey", null! }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("nullKey"));
Assert.Equal(JsonValueKind.Null, result["nullKey"].ValueKind);
}
[Fact]
public void ToA2AMetadata_WithJsonElementValue_ReturnsMetadataWithJsonElement()
{
// Arrange
JsonElement jsonElement = JsonSerializer.SerializeToElement(new { name = "test", value = 123 });
AdditionalPropertiesDictionary additionalProperties = new()
{
{ "jsonElementKey", jsonElement }
};
// Act
Dictionary<string, JsonElement>? result = additionalProperties.ToA2AMetadata();
// Assert
Assert.NotNull(result);
Assert.Single(result);
Assert.True(result.ContainsKey("jsonElementKey"));
Assert.Equal(JsonValueKind.Object, result["jsonElementKey"].ValueKind);
Assert.Equal("test", result["jsonElementKey"].GetProperty("name").GetString());
Assert.Equal(123, result["jsonElementKey"].GetProperty("value").GetInt32());
}
}
@@ -1,128 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.UnitTests.ChatClient;
public class ChatClientAgentContinuationTokenTests
{
[Fact]
public void ToBytes_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
ReadOnlyMemory<byte> bytes = chatClientToken.ToBytes();
ChatClientAgentContinuationToken tokenFromBytes = ChatClientAgentContinuationToken.FromToken(ResponseContinuationToken.FromBytes(bytes));
// Assert
Assert.NotNull(tokenFromBytes);
Assert.Equal(chatClientToken.ToBytes().ToArray(), tokenFromBytes.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), tokenFromBytes.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(tokenFromBytes.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), tokenFromBytes.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, tokenFromBytes.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, tokenFromBytes.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(tokenFromBytes.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, tokenFromBytes.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, tokenFromBytes.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, tokenFromBytes.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void Serialization_Roundtrip()
{
// Arrange
ResponseContinuationToken originalToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 });
ChatClientAgentContinuationToken chatClientToken = new(originalToken)
{
InputMessages =
[
new ChatMessage(ChatRole.User, "Hello!"),
new ChatMessage(ChatRole.User, "How are you?")
],
ResponseUpdates =
[
new ChatResponseUpdate(ChatRole.Assistant, "I'm fine, thank you."),
new ChatResponseUpdate(ChatRole.Assistant, "How can I assist you today?")
]
};
// Act
string json = JsonSerializer.Serialize(chatClientToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ResponseContinuationToken? deserializedToken = (ResponseContinuationToken?)JsonSerializer.Deserialize(json, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
ChatClientAgentContinuationToken deserializedChatClientToken = ChatClientAgentContinuationToken.FromToken(deserializedToken!);
// Assert
Assert.NotNull(deserializedChatClientToken);
Assert.Equal(chatClientToken.ToBytes().ToArray(), deserializedChatClientToken.ToBytes().ToArray());
// Verify InnerToken
Assert.Equal(chatClientToken.InnerToken.ToBytes().ToArray(), deserializedChatClientToken.InnerToken.ToBytes().ToArray());
// Verify InputMessages
Assert.NotNull(deserializedChatClientToken.InputMessages);
Assert.Equal(chatClientToken.InputMessages.Count(), deserializedChatClientToken.InputMessages.Count());
for (int i = 0; i < chatClientToken.InputMessages.Count(); i++)
{
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Role, deserializedChatClientToken.InputMessages.ElementAt(i).Role);
Assert.Equal(chatClientToken.InputMessages.ElementAt(i).Text, deserializedChatClientToken.InputMessages.ElementAt(i).Text);
}
// Verify ResponseUpdates
Assert.NotNull(deserializedChatClientToken.ResponseUpdates);
Assert.Equal(chatClientToken.ResponseUpdates.Count, deserializedChatClientToken.ResponseUpdates.Count);
for (int i = 0; i < chatClientToken.ResponseUpdates.Count; i++)
{
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Role, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Role);
Assert.Equal(chatClientToken.ResponseUpdates.ElementAt(i).Text, deserializedChatClientToken.ResponseUpdates.ElementAt(i).Text);
}
}
[Fact]
public void FromToken_WithChatClientAgentContinuationToken_ReturnsSameInstance()
{
// Arrange
ChatClientAgentContinuationToken originalToken = new(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3, 4, 5 }));
// Act
ChatClientAgentContinuationToken fromToken = ChatClientAgentContinuationToken.FromToken(originalToken);
// Assert
Assert.Same(originalToken, fromToken);
}
}
@@ -18,10 +18,10 @@ public class ChatClientAgent_BackgroundResponsesTests
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
public async Task RunAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
@@ -63,15 +63,15 @@ public class ChatClientAgent_BackgroundResponsesTests
// Assert
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
public async Task RunAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
{
// Arrange
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }));
var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
@@ -104,13 +104,13 @@ public class ChatClientAgent_BackgroundResponsesTests
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task RunStreamingAsync_PropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
public async Task RunStreamingAsyncPropagatesBackgroundResponsesPropertiesToChatClientAsync(bool providePropsViaChatOptions)
{
// Arrange
ChatResponseUpdate[] returnUpdates =
@@ -119,7 +119,7 @@ public class ChatClientAgent_BackgroundResponsesTests
new ChatResponseUpdate(role: ChatRole.Assistant, content: "at?") { ConversationId = "conversation-id" },
];
var continuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
@@ -164,11 +164,11 @@ public class ChatClientAgent_BackgroundResponsesTests
Assert.NotNull(capturedChatOptions);
Assert.True(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken.InnerToken, capturedChatOptions.ContinuationToken);
Assert.Same(continuationToken, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsync_WhenPropertiesSetInBothLocations_PrioritizesAgentRunOptionsOverChatOptionsAsync()
public async Task RunStreamingAsyncPrioritizesBackgroundResponsesPropertiesFromAgentRunOptionsOverOnesFromChatOptionsAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
@@ -176,8 +176,8 @@ public class ChatClientAgent_BackgroundResponsesTests
new ChatResponseUpdate(role: ChatRole.Assistant, content: "wh") { ConversationId = "conversation-id" },
];
var continuationToken1 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
var continuationToken2 = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] };
var continuationToken1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
var continuationToken2 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
ChatOptions? capturedChatOptions = null;
Mock<IChatClient> mockChatClient = new();
mockChatClient
@@ -212,11 +212,11 @@ public class ChatClientAgent_BackgroundResponsesTests
// Assert
Assert.NotNull(capturedChatOptions);
Assert.False(capturedChatOptions.AllowBackgroundResponses);
Assert.Same(continuationToken2.InnerToken, capturedChatOptions.ContinuationToken);
Assert.Same(continuationToken2, capturedChatOptions.ContinuationToken);
}
[Fact]
public async Task RunAsync_WhenContinuationTokenReceivedFromChatResponse_WrapsContinuationTokenAsync()
public async Task RunAsyncPropagatesContinuationTokenFromChatResponseToAgentRunResponseAsync()
{
// Arrange
var continuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
@@ -237,11 +237,11 @@ public class ChatClientAgent_BackgroundResponsesTests
var response = await agent.RunAsync([new(ChatRole.User, "hi")], thread, options: runOptions);
// Assert
Assert.Same(continuationToken, (response.ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
Assert.Same(continuationToken, response.ContinuationToken);
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenReceived_WrapsContinuationTokenAsync()
public async Task RunStreamingAsyncPropagatesContinuationTokensFromUpdatesAsync()
{
// Arrange
var token1 = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 });
@@ -272,19 +272,19 @@ public class ChatClientAgent_BackgroundResponsesTests
// Assert
Assert.Equal(2, actualUpdates.Count);
Assert.Same(token1, (actualUpdates[0].ContinuationToken as ChatClientAgentContinuationToken)?.InnerToken);
Assert.Same(token1, actualUpdates[0].ContinuationToken);
Assert.Null(actualUpdates[1].ContinuationToken); // last update has null token
}
[Fact]
public async Task RunAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
public async Task RunAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
@@ -301,14 +301,14 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunStreamingAsync_WhenMessagesProvidedWithContinuationToken_ThrowsInvalidOperationExceptionAsync()
public async Task RunStreamingAsyncThrowsWhenMessagesProvidedWithContinuationTokenAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
AgentRunOptions runOptions = new() { ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) };
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
IEnumerable<ChatMessage> inputMessages = [new ChatMessage(ChatRole.User, "test message")];
@@ -331,7 +331,7 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
public async Task RunAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
@@ -371,10 +371,7 @@ public class ChatClientAgent_BackgroundResponsesTests
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
};
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Act
await agent.RunAsync([], thread, options: runOptions);
@@ -396,7 +393,7 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunStreamingAsync_WhenContinuationTokenProvided_SkipsThreadMessagePopulationAsync()
public async Task RunStreamingAsyncSkipsThreadMessagePopulationWithContinuationTokenAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
@@ -436,15 +433,14 @@ public class ChatClientAgent_BackgroundResponsesTests
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 })) { InputMessages = [new ChatMessage()] }
};
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync());
// Assert
Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message);
// With continuation token, thread message population should be skipped
Assert.Empty(capturedMessages);
@@ -460,7 +456,7 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
public async Task RunAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
@@ -484,7 +480,7 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunStreamingAsync_WhenNoThreadProvidedForBackgroundResponses_ThrowsInvalidOperationExceptionAsync()
public async Task RunStreamingAsyncThrowsWhenNoThreadProvideForBackgroundResponsesAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
@@ -514,287 +510,126 @@ public class ChatClientAgent_BackgroundResponsesTests
}
[Fact]
public async Task RunStreamingAsync_WhenInputMessagesPresentInContinuationToken_ResumesStreamingAsync()
public async Task RunAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
// Create a new thread with no ConversationId and no MessageStore (initial run state)
ChatClientAgentThread thread = new();
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "previous message")]
}
};
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
{
updates.Add(update);
}
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => agent.RunAsync(thread: thread, options: runOptions));
Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message);
// Assert
Assert.Single(updates);
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Never);
}
// Verify that the IChatClient was called
[Fact]
public async Task RunStreamingAsyncThrowsWhenContinuationTokenProvidedForInitialRunAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
ChatClientAgent agent = new(mockChatClient.Object);
// Create a new thread with no ConversationId and no MessageStore (initial run state)
ChatClientAgentThread thread = new();
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
Assert.Equal("Continuation tokens are not allowed to be used for initial runs.", exception.Message);
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenResponseUpdatesPresentInContinuationToken_ResumesStreamingAsync()
public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithClientSideManagedChatHistoryAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "continuation") { ConversationId = "conversation-id" },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { ConversationId = "conversation-id" };
AgentRunOptions runOptions = new()
// Create a thread with a MessageStore
ChatClientAgentThread thread = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "previous update")]
}
MessageStore = new InMemoryChatMessageStore(), // Setting a message store to skip checking the continuation token in the initial run
ConversationId = null, // No conversation ID to simulate client-side managed chat history
};
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options: runOptions))
{
updates.Add(update);
}
// Create run options with a continuation token
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Assert
Assert.Single(updates);
// Act & Assert
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
Assert.Equal("Streaming resumption is only supported when chat history is stored and managed by the underlying AI service.", exception.Message);
// Verify that the IChatClient was called
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
Times.Never);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesUpdatesFromInitialRunForContextProviderAndMessageStoreAsync()
public async Task RunStreamingAsyncThrowsWhenContinuationTokenUsedWithAIContextProviderAsync()
{
// Arrange
ChatResponseUpdate[] returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "upon"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a"),
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"),
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
List<ChatMessage> capturedMessagesAddedToStore = [];
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.ResponseMessages ?? []))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
// Create a mock AIContextProvider
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Setup(p => p.InvokingAsync(It.IsAny<AIContextProvider.InvokingContext>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new AIContext());
mockContextProvider
.Setup(p => p.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
// Create a thread with an AIContextProvider and conversation ID to simulate non-initial run
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
ConversationId = "existing-conversation-id",
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
ResponseUpdates = [new ChatResponseUpdate(ChatRole.Assistant, "once ")]
}
};
AgentRunOptions runOptions = new() { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) };
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
// Act & Assert
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () => await agent.RunStreamingAsync(thread: thread, options: runOptions).ToListAsync());
// Assert
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.Single(capturedMessagesAddedToStore);
Assert.Contains("once upon a time", capturedMessagesAddedToStore[0].Text);
Assert.Equal("Using context provider with streaming resumption is not supported.", exception.Message);
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedInvokedContext?.ResponseMessages);
Assert.Single(capturedInvokedContext.ResponseMessages);
Assert.Contains("once upon a time", capturedInvokedContext.ResponseMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_UsesInputMessagesFromInitialRunForContextProviderAndMessageStoreAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
// Verify that the IChatClient was never called due to early validation
mockChatClient.Verify(
c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(Array.Empty<ChatResponseUpdate>()));
ChatClientAgent agent = new(mockChatClient.Object);
List<ChatMessage> capturedMessagesAddedToStore = [];
var mockMessageStore = new Mock<ChatMessageStore>();
mockMessageStore
.Setup(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<ChatMessageStore.InvokedContext, CancellationToken>((ctx, ct) => capturedMessagesAddedToStore.AddRange(ctx.RequestMessages))
.Returns(new ValueTask());
AIContextProvider.InvokedContext? capturedInvokedContext = null;
var mockContextProvider = new Mock<AIContextProvider>();
mockContextProvider
.Setup(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()))
.Callback<AIContextProvider.InvokedContext, CancellationToken>((context, ct) => capturedInvokedContext = context)
.Returns(new ValueTask());
ChatClientAgentThread thread = new()
{
MessageStore = mockMessageStore.Object,
AIContextProvider = mockContextProvider.Object
};
AgentRunOptions runOptions = new()
{
ContinuationToken = new ChatClientAgentContinuationToken(ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }))
{
InputMessages = [new ChatMessage(ChatRole.User, "Tell me a story")],
}
};
// Act
await agent.RunStreamingAsync(thread, options: runOptions).ToListAsync();
// Assert
mockMessageStore.Verify(ms => ms.InvokedAsync(It.IsAny<ChatMessageStore.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.Single(capturedMessagesAddedToStore);
Assert.Contains("Tell me a story", capturedMessagesAddedToStore[0].Text);
mockContextProvider.Verify(cp => cp.InvokedAsync(It.IsAny<AIContextProvider.InvokedContext>(), It.IsAny<CancellationToken>()), Times.Once);
Assert.NotNull(capturedInvokedContext?.RequestMessages);
Assert.Single(capturedInvokedContext.RequestMessages);
Assert.Contains("Tell me a story", capturedInvokedContext.RequestMessages.ElementAt(0).Text);
}
[Fact]
public async Task RunStreamingAsync_WhenResumingStreaming_SavesInputMessagesAndUpdatesInContinuationTokenAsync()
{
// Arrange
List<ChatResponseUpdate> returnUpdates =
[
new ChatResponseUpdate(role: ChatRole.Assistant, content: "Once") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " upon") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " a") { ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
new ChatResponseUpdate(role: ChatRole.Assistant, content: " time"){ ContinuationToken = ResponseContinuationToken.FromBytes(new byte[] { 1, 2, 3 }) },
];
Mock<IChatClient> mockChatClient = new();
mockChatClient
.Setup(c => c.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns(ToAsyncEnumerableAsync(returnUpdates));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentThread thread = new() { };
List<ChatClientAgentContinuationToken> capturedContinuationTokens = [];
ChatMessage userMessage = new(ChatRole.User, "Tell me a story");
// Act
// Do the initial run
await foreach (var update in agent.RunStreamingAsync(userMessage, thread))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
break;
}
// Now resume the run using the captured continuation token
returnUpdates.RemoveAt(0); // remove the first mock update as it was already processed
var options = new AgentRunOptions { ContinuationToken = capturedContinuationTokens[0] };
await foreach (var update in agent.RunStreamingAsync(thread, options: options))
{
capturedContinuationTokens.Add(Assert.IsType<ChatClientAgentContinuationToken>(update.ContinuationToken));
}
// Assert
Assert.Equal(4, capturedContinuationTokens.Count);
// Verify that the first continuation token has the initial input and first update
Assert.NotNull(capturedContinuationTokens[0].InputMessages);
Assert.Single(capturedContinuationTokens[0].InputMessages!);
Assert.Equal("Tell me a story", capturedContinuationTokens[0].InputMessages!.Last().Text);
Assert.NotNull(capturedContinuationTokens[0].ResponseUpdates);
Assert.Single(capturedContinuationTokens[0].ResponseUpdates!);
Assert.Equal("Once", capturedContinuationTokens[0].ResponseUpdates![0].Text);
// Verify the last continuation token has the input and all updates
var lastToken = capturedContinuationTokens[^1];
Assert.NotNull(lastToken.InputMessages);
Assert.Single(lastToken.InputMessages!);
Assert.Equal("Tell me a story", lastToken.InputMessages!.Last().Text);
Assert.NotNull(lastToken.ResponseUpdates);
Assert.Equal(4, lastToken.ResponseUpdates!.Count);
Assert.Equal("Once", lastToken.ResponseUpdates!.ElementAt(0).Text);
Assert.Equal(" upon", lastToken.ResponseUpdates!.ElementAt(1).Text);
Assert.Equal(" a", lastToken.ResponseUpdates!.ElementAt(2).Text);
Assert.Equal(" time", lastToken.ResponseUpdates!.ElementAt(3).Text);
It.IsAny<CancellationToken>()),
Times.Never);
}
private static async IAsyncEnumerable<T> ToAsyncEnumerableAsync<T>(IEnumerable<T> values)
@@ -1,456 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Tests for <see cref="ChatClientAgent"/> run methods with <see cref="ChatClientAgentRunOptions"/>.
/// </summary>
public sealed partial class ChatClientAgent_RunWithCustomOptionsTests
{
#region RunAsync Tests
[Fact]
public async Task RunAsync_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse result = await agent.RunAsync(thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse result = await agent.RunAsync("Test message", thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse result = await agent.RunAsync(message, thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse result = await agent.RunAsync(messages, thread, options);
// Assert
Assert.NotNull(result);
Assert.Single(result.Messages);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsync_WithChatOptionsInRunOptions_UsesChatOptionsAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "Response")]));
ChatClientAgent agent = new(mockChatClient.Object);
ChatClientAgentRunOptions options = new(new ChatOptions { Temperature = 0.5f });
// Act
AgentRunResponse result = await agent.RunAsync("Test", null, options);
// Assert
Assert.NotNull(result);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.Is<ChatOptions>(opts => opts.Temperature == 0.5f),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region RunStreamingAsync Tests
[Fact]
public async Task RunStreamingAsync_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync("Test message", thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(message, thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunStreamingAsync_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).Returns(GetAsyncUpdatesAsync());
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
IEnumerable<ChatMessage> messages = [new ChatMessage(ChatRole.User, "Message 1"), new ChatMessage(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, thread, options))
{
updates.Add(update);
}
// Assert
Assert.NotEmpty(updates);
mockChatClient.Verify(
x => x.GetStreamingResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
#region Helper Methods
private static async IAsyncEnumerable<ChatResponseUpdate> GetAsyncUpdatesAsync()
{
yield return new ChatResponseUpdate { Contents = new[] { new TextContent("Hello") } };
yield return new ChatResponseUpdate { Contents = new[] { new TextContent(" World") } };
await Task.CompletedTask;
}
#endregion
#region RunAsync{T} Tests
[Fact]
public async Task RunAsyncOfT_WithThreadAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentRunResponse);
Assert.Single(agentRunResponse.Messages);
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithStringMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>("Test message", thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentRunResponse);
Assert.Single(agentRunResponse.Messages);
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Any(m => m.Text == "Test message")),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithChatMessageAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
ChatMessage message = new(ChatRole.User, "Test message");
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(message, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentRunResponse);
Assert.Single(agentRunResponse.Messages);
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.Is<IEnumerable<ChatMessage>>(msgs => msgs.Contains(message)),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RunAsyncOfT_WithMessagesCollectionAndOptions_CallsBaseMethodAsync()
{
// Arrange
Mock<IChatClient> mockChatClient = new();
mockChatClient.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, """{"id":2, "fullName":"Tigger", "species":"Tiger"}""")]));
ChatClientAgent agent = new(mockChatClient.Object);
AgentThread thread = agent.GetNewThread();
IEnumerable<ChatMessage> messages = [new(ChatRole.User, "Message 1"), new(ChatRole.User, "Message 2")];
ChatClientAgentRunOptions options = new();
// Act
AgentRunResponse<Animal> agentRunResponse = await agent.RunAsync<Animal>(messages, thread, JsonContext_WithCustomRunOptions.Default.Options, options);
// Assert
Assert.NotNull(agentRunResponse);
Assert.Single(agentRunResponse.Messages);
Assert.Equal("Tigger", agentRunResponse.Result.FullName);
mockChatClient.Verify(
x => x.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()),
Times.Once);
}
#endregion
private sealed class Animal
{
public int Id { get; set; }
public string? FullName { get; set; }
public Species Species { get; set; }
}
private enum Species
{
Bear,
Tiger,
Walrus,
}
[JsonSourceGenerationOptions(UseStringEnumConverter = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Animal))]
private sealed partial class JsonContext_WithCustomRunOptions : JsonSerializerContext;
}
@@ -1,114 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public sealed class ExpectedException : Exception
{
public ExpectedException(string message)
: base(message)
{
}
public ExpectedException() : base()
{
}
public ExpectedException(string? message, Exception? innerException) : base(message, innerException)
{
}
}
public class WorkflowHostSmokeTests
{
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
{
private sealed class Thread : InMemoryAgentThread
{
public Thread() { }
public Thread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
: base(serializedThread, jsonSerializerOptions)
{ }
}
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
{
return new Thread(serializedThread, jsonSerializerOptions);
}
public override AgentThread GetNewThread()
{
return new Thread();
}
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return await this.RunStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
const string ErrorMessage = "Simulated agent failure.";
if (failByThrowing)
{
throw new ExpectedException(ErrorMessage);
}
yield return new AgentRunResponseUpdate(ChatRole.Assistant, [new ErrorContent(ErrorMessage)]);
}
}
private static Workflow CreateWorkflow(bool failByThrowing)
{
ExecutorBinding agent = new AlwaysFailsAIAgent(failByThrowing).BindAsExecutor(emitEvents: true);
return new WorkflowBuilder(agent).Build();
}
[Theory]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptionDetails, bool failByThrowing)
{
string expectedMessage = !failByThrowing || includeExceptionDetails
? "Simulated agent failure."
: "An error occurred while executing the workflow.";
// Arrange is done by the caller.
Workflow workflow = CreateWorkflow(failByThrowing);
// Act
List<AgentRunResponseUpdate> updates = await workflow.AsAgent("WorkflowAgent", includeExceptionDetails: includeExceptionDetails)
.RunStreamingAsync(new ChatMessage(ChatRole.User, "Hello"))
.ToListAsync();
// Assert
bool hadErrorContent = false;
foreach (AgentRunResponseUpdate update in updates)
{
if (update.Contents.Any())
{
// We should expect a single update which contains the error content.
update.Contents.Should().ContainSingle()
.Which.Should().BeOfType<ErrorContent>()
.Which.Message.Should().Be(expectedMessage);
hadErrorContent = true;
}
}
hadErrorContent.Should().BeTrue();
}
}
+1 -38
View File
@@ -7,41 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b260107] - 2026-01-07
### Added
- **agent-framework-devui**: Improve DevUI and add Context Inspector view as a new tab under traces ([#2742](https://github.com/microsoft/agent-framework/pull/2742)) by @victordibia
- **samples**: Add streaming sample for Azure Functions ([#3057](https://github.com/microsoft/agent-framework/pull/3057)) by @gavin-aguiar
### Changed
- **repo**: Update templates ([#3106](https://github.com/microsoft/agent-framework/pull/3106)) by @eavanvalkenburg
### Fixed
- **agent-framework-ag-ui**: Fix MCP tool result serialization for list[TextContent] ([#2523](https://github.com/microsoft/agent-framework/pull/2523)) by @claude89757
- **agent-framework-azure-ai**: Fix response_format handling for structured outputs ([#3114](https://github.com/microsoft/agent-framework/pull/3114)) by @moonbox3
## [1.0.0b260106] - 2026-01-06
### Added
- **repo**: Add issue template and additional labeling ([#3006](https://github.com/microsoft/agent-framework/pull/3006)) by @eavanvalkenburg
### Changed
- None
### Fixed
- **agent-framework-core**: Fix max tokens translation and add extra integer test ([#3037](https://github.com/microsoft/agent-framework/pull/3037)) by @eavanvalkenburg
- **agent-framework-azure-ai**: Fix failure when conversation history contains assistant messages ([#3076](https://github.com/microsoft/agent-framework/pull/3076)) by @moonbox3
- **agent-framework-core**: Use HTTP exporter for http/protobuf protocol ([#3070](https://github.com/microsoft/agent-framework/pull/3070)) by @takanori-terai
- **agent-framework-core**: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data ([#3090](https://github.com/microsoft/agent-framework/pull/3090)) by @moonbox3
- **agent-framework-core**: Honor tool_choice parameter passed to agent.run() and chat client methods ([#3095](https://github.com/microsoft/agent-framework/pull/3095)) by @moonbox3
- **samples**: AzureAI SharePoint sample fix ([#3108](https://github.com/microsoft/agent-framework/pull/3108)) by @giles17
## [1.0.0b251223] - 2025-12-23
### Added
@@ -461,9 +426,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260107...HEAD
[1.0.0b260107]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...python-1.0.0b260107
[1.0.0b260106]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...python-1.0.0b260106
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251223...HEAD
[1.0.0b251223]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251218...python-1.0.0b251223
[1.0.0b251218]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...python-1.0.0b251218
[1.0.0b251216]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...python-1.0.0b251216
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -11,61 +11,21 @@ from typing import Any
class ConfirmationStrategy(ABC):
"""Strategy for generating confirmation messages during human-in-the-loop flows.
"""Strategy for generating confirmation messages during human-in-the-loop flows."""
Subclasses must define the message properties. The methods use those properties
by default, but can be overridden for complete customization.
"""
@property
@abstractmethod
def approval_header(self) -> str:
"""Header for approval accepted message. Must be overridden."""
...
@property
@abstractmethod
def approval_footer(self) -> str:
"""Footer for approval accepted message. Must be overridden."""
...
@property
@abstractmethod
def rejection_message(self) -> str:
"""Message when user rejects. Must be overridden."""
...
@property
@abstractmethod
def state_confirmed_message(self) -> str:
"""Message when state is confirmed. Must be overridden."""
...
@property
@abstractmethod
def state_rejected_message(self) -> str:
"""Message when state is rejected. Must be overridden."""
...
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate message when user approves function execution.
Default implementation uses header/footer properties.
Override for complete customization.
Args:
steps: List of approved steps with 'description', 'status', etc.
Returns:
Message to display to user
"""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
message_parts = [self.approval_header.format(count=len(enabled_steps))]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append(self.approval_footer)
return "".join(message_parts)
...
@abstractmethod
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate message when user rejects function execution.
@@ -75,143 +35,141 @@ class ConfirmationStrategy(ABC):
Returns:
Message to display to user
"""
return self.rejection_message
...
@abstractmethod
def on_state_confirmed(self) -> str:
"""Generate message when user confirms predictive state changes.
Returns:
Message to display to user
"""
return self.state_confirmed_message
...
@abstractmethod
def on_state_rejected(self) -> str:
"""Generate message when user rejects predictive state changes.
Returns:
Message to display to user
"""
return self.state_rejected_message
...
class DefaultConfirmationStrategy(ConfirmationStrategy):
"""Generic confirmation messages suitable for most agents."""
"""Generic confirmation messages suitable for most agents.
@property
def approval_header(self) -> str:
return "Executing {count} approved steps:\n\n"
This preserves the original behavior from v1.
"""
@property
def approval_footer(self) -> str:
return "\nAll steps completed successfully!"
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate generic approval message with step list."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
@property
def rejection_message(self) -> str:
message_parts = [f"Executing {len(enabled_steps)} approved steps:\n\n"]
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nAll steps completed successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate generic rejection message."""
return "No problem! What would you like me to change about the plan?"
@property
def state_confirmed_message(self) -> str:
def on_state_confirmed(self) -> str:
"""Generate generic state confirmation message."""
return "Changes confirmed and applied successfully!"
@property
def state_rejected_message(self) -> str:
def on_state_rejected(self) -> str:
"""Generate generic state rejection message."""
return "No problem! What would you like me to change?"
class TaskPlannerConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for task planning agents."""
@property
def approval_header(self) -> str:
return "Executing your requested tasks:\n\n"
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate task-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
@property
def approval_footer(self) -> str:
return "\nAll tasks completed successfully!"
message_parts = ["Executing your requested tasks:\n\n"]
@property
def rejection_message(self) -> str:
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nAll tasks completed successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate task-specific rejection message."""
return "No problem! Let me revise the plan. What would you like me to change?"
@property
def state_confirmed_message(self) -> str:
def on_state_confirmed(self) -> str:
"""Task planners typically don't use state confirmation."""
return "Tasks confirmed and ready to execute!"
@property
def state_rejected_message(self) -> str:
def on_state_rejected(self) -> str:
"""Task planners typically don't use state confirmation."""
return "No problem! How should I adjust the task list?"
class RecipeConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for recipe agents."""
@property
def approval_header(self) -> str:
return "Updating your recipe:\n\n"
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate recipe-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
@property
def approval_footer(self) -> str:
return "\nRecipe updated successfully!"
message_parts = ["Updating your recipe:\n\n"]
@property
def rejection_message(self) -> str:
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nRecipe updated successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate recipe-specific rejection message."""
return "No problem! What ingredients or steps should I change?"
@property
def state_confirmed_message(self) -> str:
def on_state_confirmed(self) -> str:
"""Generate recipe-specific state confirmation message."""
return "Recipe changes applied successfully!"
@property
def state_rejected_message(self) -> str:
def on_state_rejected(self) -> str:
"""Generate recipe-specific state rejection message."""
return "No problem! What would you like me to adjust in the recipe?"
class DocumentWriterConfirmationStrategy(ConfirmationStrategy):
"""Domain-specific confirmation messages for document writing agents."""
@property
def approval_header(self) -> str:
return "Applying your edits:\n\n"
def on_approval_accepted(self, steps: list[dict[str, Any]]) -> str:
"""Generate document-specific approval message."""
enabled_steps = [s for s in steps if s.get("status") == "enabled"]
@property
def approval_footer(self) -> str:
return "\nDocument updated successfully!"
message_parts = ["Applying your edits:\n\n"]
@property
def rejection_message(self) -> str:
for i, step in enumerate(enabled_steps, 1):
message_parts.append(f"{i}. {step['description']}\n")
message_parts.append("\nDocument updated successfully!")
return "".join(message_parts)
def on_approval_rejected(self, steps: list[dict[str, Any]]) -> str:
"""Generate document-specific rejection message."""
return "No problem! Which changes should I keep or modify?"
@property
def state_confirmed_message(self) -> str:
def on_state_confirmed(self) -> str:
"""Generate document-specific state confirmation message."""
return "Document edits applied!"
@property
def state_rejected_message(self) -> str:
def on_state_rejected(self) -> str:
"""Generate document-specific state rejection message."""
return "No problem! What should I change about the document?"
def apply_confirmation_strategy(
strategy: ConfirmationStrategy | None,
accepted: bool,
steps: list[dict[str, Any]],
) -> str:
"""Apply a confirmation strategy to generate a message.
This helper consolidates the pattern used in multiple orchestrators.
Args:
strategy: Strategy to use, or None for default
accepted: Whether the user approved
steps: List of steps (may be empty for state confirmations)
Returns:
Generated message string
"""
if strategy is None:
strategy = DefaultConfirmationStrategy()
if not steps:
# State confirmation (no steps)
return strategy.on_state_confirmed() if accepted else strategy.on_state_rejected()
# Step-based approval
return strategy.on_approval_accepted(steps) if accepted else strategy.on_approval_rejected(steps)
@@ -11,6 +11,8 @@ from typing import Any
from ag_ui.core import (
BaseEvent,
CustomEvent,
EventType,
MessagesSnapshotEvent,
RunFinishedEvent,
RunStartedEvent,
StateDeltaEvent,
@@ -29,10 +31,9 @@ from agent_framework import (
FunctionCallContent,
FunctionResultContent,
TextContent,
prepare_function_call_results,
)
from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse
from ._utils import generate_event_id
logger = logging.getLogger(__name__)
@@ -47,8 +48,8 @@ class AgentFrameworkEventBridge:
predict_state_config: dict[str, dict[str, str]] | None = None,
current_state: dict[str, Any] | None = None,
skip_text_content: bool = False,
input_messages: list[Any] | None = None,
require_confirmation: bool = True,
approval_tool_name: str | None = None,
) -> None:
"""
Initialize the event bridge.
@@ -60,6 +61,7 @@ class AgentFrameworkEventBridge:
Format: {"state_key": {"tool": "tool_name", "tool_argument": "arg_name"}}
current_state: Reference to the current state dict for tracking updates.
skip_text_content: If True, skip emitting TextMessageContentEvents (for structured outputs).
input_messages: The input messages from the conversation history.
require_confirmation: Whether predictive state updates require user confirmation.
"""
self.run_id = run_id
@@ -72,7 +74,6 @@ class AgentFrameworkEventBridge:
self.pending_state_updates: dict[str, Any] = {} # Track updates from tool calls
self.skip_text_content = skip_text_content
self.require_confirmation = require_confirmation
self.approval_tool_name = approval_tool_name
# For predictive state updates: accumulate streaming arguments
self.streaming_tool_args: str = "" # Accumulated JSON string
@@ -81,6 +82,13 @@ class AgentFrameworkEventBridge:
self.should_stop_after_confirm: bool = False # Flag to stop run after confirm_changes
self.suppressed_summary: str = "" # Store LLM summary to show after confirmation
# For MessagesSnapshotEvent: track tool calls and results
self.input_messages = input_messages or []
self.pending_tool_calls: list[dict[str, Any]] = [] # Track tool calls for assistant message
self.tool_results: list[dict[str, Any]] = [] # Track tool results
self.tool_calls_ended: set[str] = set() # Track which tool calls have had ToolCallEndEvent emitted
self.accumulated_text_content: str = "" # Track accumulated text for final MessagesSnapshotEvent
async def from_agent_run_update(self, update: AgentRunResponseUpdate) -> list[BaseEvent]:
"""
Convert an AgentRunResponseUpdate to AG-UI events.
@@ -146,6 +154,7 @@ class AgentFrameworkEventBridge:
message_id=self.current_message_id,
delta=content.text,
)
self.accumulated_text_content += content.text
logger.info(f" EMITTING TextMessageContentEvent with text_len={len(content.text)}")
events.append(event)
return events
@@ -160,10 +169,10 @@ class AgentFrameworkEventBridge:
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
tool_call_id = self._coalesce_tool_call_id(content)
# Only emit ToolCallStartEvent once per tool call (when it's a new tool call)
if content.name and tool_call_id != self.current_tool_call_id:
self.streaming_tool_args = ""
self.state_delta_count = 0
if content.name:
self.current_tool_call_id = tool_call_id
self.current_tool_call_name = content.name
@@ -174,6 +183,17 @@ class AgentFrameworkEventBridge:
)
logger.info(f"Emitting ToolCallStartEvent with name='{content.name}', id='{tool_call_id}'")
events.append(tool_start_event)
self.pending_tool_calls.append(
{
"id": tool_call_id,
"type": "function",
"function": {
"name": content.name,
"arguments": "",
},
}
)
elif tool_call_id:
self.current_tool_call_id = tool_call_id
@@ -186,7 +206,13 @@ class AgentFrameworkEventBridge:
)
events.append(args_event)
for tool_call in self.pending_tool_calls:
if tool_call["id"] == tool_call_id:
tool_call["function"]["arguments"] += delta_str
break
events.extend(self._emit_predictive_state_deltas(delta_str))
events.extend(self._legacy_predictive_state(content))
return events
@@ -209,8 +235,10 @@ class AgentFrameworkEventBridge:
self.current_tool_call_name,
)
parsed_args = safe_json_parse(self.streaming_tool_args)
if parsed_args is None:
parsed_args = None
try:
parsed_args = json.loads(self.streaming_tool_args)
except json.JSONDecodeError:
for state_key, config in self.predict_state_config.items():
if config["tool"] != self.current_tool_call_name:
continue
@@ -254,8 +282,11 @@ class AgentFrameworkEventBridge:
continue
tool_arg_name = config["tool_argument"]
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
if state_value is None:
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
continue
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
@@ -286,6 +317,59 @@ class AgentFrameworkEventBridge:
self.pending_state_updates[state_key] = state_value
return events
def _legacy_predictive_state(self, content: FunctionCallContent) -> list[BaseEvent]:
events: list[BaseEvent] = []
if not (content.name and content.arguments):
return events
parsed_args = content.parse_arguments()
if not parsed_args:
return events
logger.info(
"Checking predict_state_config keys: %s",
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
)
for state_key, config in self.predict_state_config.items():
logger.info(f"Checking state_key='{state_key}'")
if config["tool"] != content.name:
continue
tool_arg_name = config["tool_argument"]
logger.info(f"MATCHED tool '{content.name}' for state key '{state_key}', arg='{tool_arg_name}'")
state_value: Any
if tool_arg_name == "*":
state_value = parsed_args
logger.info(f"Using all args as state value, keys: {list(state_value.keys())}")
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
logger.info(f"Using specific arg '{tool_arg_name}' as state value")
else:
logger.warning(f"Tool argument '{tool_arg_name}' not found in parsed args")
continue
previous_value = self.last_emitted_state.get(state_key, object())
if previous_value == state_value:
logger.info(
"Skipping duplicate StateDeltaEvent for key '%s' - value unchanged",
state_key,
)
continue
state_delta_event = StateDeltaEvent(
delta=[
{
"op": "replace",
"path": f"/{state_key}",
"value": state_value,
}
],
)
logger.info(f"Emitting StateDeltaEvent for key '{state_key}', value type: {type(state_value)}") # type: ignore
events.append(state_delta_event)
self.pending_state_updates[state_key] = state_value
self.last_emitted_state[state_key] = state_value
return events
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
events: list[BaseEvent] = []
if content.call_id:
@@ -294,6 +378,7 @@ class AgentFrameworkEventBridge:
)
logger.info(f"Emitting ToolCallEndEvent for completed tool call '{content.call_id}'")
events.append(end_event)
self.tool_calls_ended.add(content.call_id)
if self.state_delta_count > 0:
logger.info(
@@ -306,7 +391,12 @@ class AgentFrameworkEventBridge:
self.state_delta_count = 0
result_message_id = generate_event_id()
result_content = prepare_function_call_results(content.result)
if isinstance(content.result, dict):
result_content = json.dumps(content.result) # type: ignore[arg-type]
elif content.result is not None:
result_content = str(content.result)
else:
result_content = ""
result_event = ToolCallResultEvent(
message_id=result_message_id,
@@ -315,10 +405,55 @@ class AgentFrameworkEventBridge:
role="tool",
)
events.append(result_event)
self.tool_results.append(
{
"id": result_message_id,
"role": "tool",
"toolCallId": content.call_id,
"content": result_content,
}
)
events.extend(self._emit_snapshot_for_tool_result())
events.extend(self._emit_state_snapshot_and_confirmation())
return events
def _emit_snapshot_for_tool_result(self) -> list[BaseEvent]:
events: list[BaseEvent] = []
should_emit_snapshot = self.pending_tool_calls and self.tool_results
is_predictive_without_confirmation = False
if should_emit_snapshot and self.current_tool_call_name and self.predict_state_config:
for _, config in self.predict_state_config.items():
if config["tool"] == self.current_tool_call_name and not self.require_confirmation:
is_predictive_without_confirmation = True
logger.info(
"Skipping intermediate MessagesSnapshotEvent for predictive tool '%s' - delaying until summary",
self.current_tool_call_name,
)
break
if should_emit_snapshot and not is_predictive_without_confirmation:
from ._message_adapters import agent_framework_messages_to_agui
assistant_message = {
"id": generate_event_id(),
"role": "assistant",
"tool_calls": self.pending_tool_calls.copy(),
}
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(f"Emitting MessagesSnapshotEvent with {len(all_messages)} messages")
events.append(messages_snapshot_event)
return events
def _emit_state_snapshot_and_confirmation(self) -> list[BaseEvent]:
events: list[BaseEvent] = []
if self.pending_state_updates:
@@ -367,46 +502,31 @@ class AgentFrameworkEventBridge:
self.current_tool_call_name = None
return events
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
"""Emit a confirm_changes tool call for Dojo UI compatibility.
Args:
function_call: Optional function call that needs confirmation.
If provided, includes function info in the confirm_changes args
so Dojo UI can display what's being confirmed.
"""
def _emit_confirm_changes_tool_call(self) -> list[BaseEvent]:
events: list[BaseEvent] = []
confirm_call_id = generate_event_id()
logger.info("Emitting confirm_changes tool call for predictive update")
self.pending_tool_calls.append(
{
"id": confirm_call_id,
"type": "function",
"function": {
"name": "confirm_changes",
"arguments": "{}",
},
}
)
confirm_start = ToolCallStartEvent(
tool_call_id=confirm_call_id,
tool_call_name="confirm_changes",
parent_message_id=self.current_message_id,
)
events.append(confirm_start)
# Include function info if this is for a function approval
# This helps Dojo UI display meaningful confirmation info
if function_call:
args_dict = {
"function_name": function_call.name,
"function_call_id": function_call.call_id,
"function_arguments": function_call.parse_arguments() or {},
"steps": [
{
"description": f"Execute {function_call.name}",
"status": "enabled",
}
],
}
args_json = json.dumps(args_dict)
else:
args_json = "{}"
confirm_args = ToolCallArgsEvent(
tool_call_id=confirm_call_id,
delta=args_json,
delta="{}",
)
events.append(confirm_args)
@@ -415,48 +535,23 @@ class AgentFrameworkEventBridge:
)
events.append(confirm_end)
self.should_stop_after_confirm = True
logger.info("Set flag to stop run after confirm_changes")
return events
from ._message_adapters import agent_framework_messages_to_agui
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
"""Emit a tool call that can drive UI approval for function requests."""
tool_call_name = "confirm_changes"
if self.approval_tool_name and self.approval_tool_name != function_call.name:
tool_call_name = self.approval_tool_name
tool_call_id = generate_event_id()
tool_start = ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=tool_call_name,
parent_message_id=self.current_message_id,
)
events: list[BaseEvent] = [tool_start]
args_dict = {
"function_name": function_call.name,
"function_call_id": function_call.call_id,
"function_arguments": function_call.parse_arguments() or {},
"steps": [
{
"description": f"Execute {function_call.name}",
"status": "enabled",
}
],
assistant_message = {
"id": generate_event_id(),
"role": "assistant",
"tool_calls": self.pending_tool_calls.copy(),
}
args_json = json.dumps(args_dict)
events.append(
ToolCallArgsEvent(
tool_call_id=tool_call_id,
delta=args_json,
)
)
events.append(
ToolCallEndEvent(
tool_call_id=tool_call_id,
)
converted_input_messages = agent_framework_messages_to_agui(self.input_messages)
all_messages = converted_input_messages + [assistant_message] + self.tool_results.copy()
messages_snapshot_event = MessagesSnapshotEvent(
type=EventType.MESSAGES_SNAPSHOT,
messages=all_messages, # type: ignore[arg-type]
)
logger.info(f"Emitting MessagesSnapshotEvent for confirm_changes with {len(all_messages)} messages")
events.append(messages_snapshot_event)
self.should_stop_after_confirm = True
logger.info("Set flag to stop run after confirm_changes")
@@ -488,8 +583,12 @@ class AgentFrameworkEventBridge:
tool_arg_name,
)
state_value = extract_state_from_tool_args(parsed_args, tool_arg_name)
if state_value is None:
state_value: Any
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
logger.warning(f" Tool argument '{tool_arg_name}' not found in parsed args")
continue
@@ -506,8 +605,8 @@ class AgentFrameworkEventBridge:
)
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
events.append(end_event)
self.tool_calls_ended.add(content.function_call.call_id)
# Emit the function_approval_request custom event for UI implementations that support it
approval_event = CustomEvent(
name="function_approval_request",
value={
@@ -521,14 +620,6 @@ class AgentFrameworkEventBridge:
)
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
events.append(approval_event)
# Emit a UI-friendly approval tool call for function approvals.
if self.require_confirmation:
events.extend(self._emit_function_approval_tool_call(content.function_call))
# Signal orchestrator to stop the run and wait for user approval response
self.should_stop_after_confirm = True
logger.info("Set flag to stop run - waiting for function approval response")
return events
def create_run_started_event(self) -> RunStartedEvent:
@@ -2,8 +2,6 @@
"""Message format conversion between AG-UI and Agent Framework."""
import json
import logging
from typing import Any, cast
from agent_framework import (
@@ -13,229 +11,20 @@ from agent_framework import (
FunctionResultContent,
Role,
TextContent,
prepare_function_call_results,
)
from ._utils import (
AGUI_TO_FRAMEWORK_ROLE,
FRAMEWORK_TO_AGUI_ROLE,
get_role_value,
normalize_agui_role,
safe_json_parse,
)
# Role mapping constants
_AGUI_TO_FRAMEWORK_ROLE = {
"user": Role.USER,
"assistant": Role.ASSISTANT,
"system": Role.SYSTEM,
}
logger = logging.getLogger(__name__)
def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
sanitized: list[ChatMessage] = []
pending_tool_call_ids: set[str] | None = None
pending_confirm_changes_id: str | None = None
for msg in messages:
role_value = get_role_value(msg)
if role_value == "assistant":
tool_ids = {
str(content.call_id)
for content in msg.contents or []
if isinstance(content, FunctionCallContent) and content.call_id
}
confirm_changes_call = None
for content in msg.contents or []:
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
confirm_changes_call = content
break
sanitized.append(msg)
pending_tool_call_ids = tool_ids if tool_ids else None
pending_confirm_changes_id = (
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
)
continue
if role_value == "user":
approval_call_ids: set[str] = set()
approval_accepted: bool | None = None
for content in msg.contents or []:
if type(content) is FunctionApprovalResponseContent:
if content.function_call and content.function_call.call_id:
approval_call_ids.add(str(content.function_call.call_id))
if approval_accepted is None:
approval_accepted = bool(content.approved)
else:
approval_accepted = approval_accepted and bool(content.approved)
if approval_call_ids and pending_tool_call_ids:
pending_tool_call_ids -= approval_call_ids
logger.info(
f"FunctionApprovalResponseContent found for call_ids={sorted(approval_call_ids)} - "
"framework will handle execution"
)
if pending_confirm_changes_id and approval_accepted is not None:
logger.info(f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}")
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_confirm_changes_id,
result="Confirmed" if approval_accepted else "Rejected",
)
],
)
sanitized.append(synthetic_result)
if pending_tool_call_ids:
pending_tool_call_ids.discard(pending_confirm_changes_id)
pending_confirm_changes_id = None
if pending_confirm_changes_id:
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
break
try:
parsed = json.loads(user_text)
if "accepted" in parsed:
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
)
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
)
],
)
sanitized.append(synthetic_result)
if pending_tool_call_ids:
pending_tool_call_ids.discard(pending_confirm_changes_id)
pending_confirm_changes_id = None
continue
except (json.JSONDecodeError, KeyError) as exc:
logger.debug(f"Could not parse user message as confirm_changes response: {type(exc).__name__}")
if pending_tool_call_ids:
logger.info(
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - "
"injecting synthetic results"
)
for pending_call_id in pending_tool_call_ids:
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_call_id,
result="Tool execution skipped - user provided follow-up message",
)
],
)
sanitized.append(synthetic_result)
pending_tool_call_ids = None
pending_confirm_changes_id = None
sanitized.append(msg)
pending_confirm_changes_id = None
continue
if role_value == "tool":
if not pending_tool_call_ids:
continue
keep = False
for content in msg.contents or []:
if isinstance(content, FunctionResultContent):
call_id = str(content.call_id)
if call_id in pending_tool_call_ids:
keep = True
if call_id == pending_confirm_changes_id:
pending_confirm_changes_id = None
break
if keep:
sanitized.append(msg)
continue
sanitized.append(msg)
pending_tool_call_ids = None
pending_confirm_changes_id = None
return sanitized
def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
"""Remove duplicate messages while preserving order."""
seen_keys: dict[Any, int] = {}
unique_messages: list[ChatMessage] = []
for idx, msg in enumerate(messages):
role_value = get_role_value(msg)
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
call_id = str(msg.contents[0].call_id)
key: Any = (role_value, call_id)
if key in seen_keys:
existing_idx = seen_keys[key]
existing_msg = unique_messages[existing_idx]
existing_result = None
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
existing_result = existing_msg.contents[0].result
new_result = msg.contents[0].result
if (not existing_result or existing_result == "") and new_result:
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
unique_messages[existing_idx] = msg
else:
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
elif (
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
):
tool_call_ids = tuple(
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
)
key = (role_value, tool_call_ids)
if key in seen_keys:
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
else:
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
key = (role_value, hash(content_str))
if key in seen_keys:
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
return unique_messages
def normalize_agui_input_messages(
messages: list[dict[str, Any]],
) -> tuple[list[ChatMessage], list[dict[str, Any]]]:
"""Normalize raw AG-UI messages into provider and snapshot formats."""
provider_messages = agui_messages_to_agent_framework(messages)
provider_messages = _sanitize_tool_history(provider_messages)
provider_messages = _deduplicate_messages(provider_messages)
snapshot_messages = agui_messages_to_snapshot_format(messages)
return provider_messages, snapshot_messages
_FRAMEWORK_TO_AGUI_ROLE = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
Role.SYSTEM: "system",
}
def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[ChatMessage]:
@@ -247,108 +36,11 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
Returns:
List of Agent Framework ChatMessage objects
"""
def _update_tool_call_arguments(
raw_messages: list[dict[str, Any]],
tool_call_id: str,
modified_args: dict[str, Any],
) -> None:
for raw_msg in raw_messages:
tool_calls = raw_msg.get("tool_calls") or raw_msg.get("toolCalls")
if not isinstance(tool_calls, list):
continue
tool_calls_list = cast(list[Any], tool_calls)
for tool_call in tool_calls_list:
if not isinstance(tool_call, dict):
continue
tool_call_dict = cast(dict[str, Any], tool_call)
if str(tool_call_dict.get("id", "")) != tool_call_id:
continue
function_payload = tool_call_dict.get("function")
if not isinstance(function_payload, dict):
return
function_payload_dict = cast(dict[str, Any], function_payload)
existing_args = function_payload_dict.get("arguments")
if isinstance(existing_args, str):
function_payload_dict["arguments"] = json.dumps(modified_args)
else:
function_payload_dict["arguments"] = modified_args
return
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
for prev_msg in result:
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
if role_val != "assistant":
continue
for content in prev_msg.contents or []:
if isinstance(content, FunctionCallContent):
if content.call_id == call_id and content.name != "confirm_changes":
return content
return None
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
return safe_json_parse(arguments)
def _resolve_approval_call_id(tool_call_id: str, parsed_payload: dict[str, Any] | None) -> str | None:
if parsed_payload:
explicit_call_id = parsed_payload.get("function_call_id")
if explicit_call_id:
return str(explicit_call_id)
for prev_msg in result:
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
if role_val != "assistant":
continue
direct_call = None
confirm_call = None
sibling_calls: list[FunctionCallContent] = []
for content in prev_msg.contents or []:
if not isinstance(content, FunctionCallContent):
continue
if content.call_id == tool_call_id:
direct_call = content
if content.name == "confirm_changes" and content.call_id == tool_call_id:
confirm_call = content
elif content.name != "confirm_changes":
sibling_calls.append(content)
if direct_call:
direct_args = direct_call.parse_arguments() or {}
if isinstance(direct_args, dict):
explicit_call_id = direct_args.get("function_call_id")
if explicit_call_id:
return str(explicit_call_id)
if not confirm_call:
continue
confirm_args = confirm_call.parse_arguments() or {}
if isinstance(confirm_args, dict):
explicit_call_id = confirm_args.get("function_call_id")
if explicit_call_id:
return str(explicit_call_id)
if len(sibling_calls) == 1 and sibling_calls[0].call_id:
return str(sibling_calls[0].call_id)
return None
def _filter_modified_args(
modified_args: dict[str, Any],
original_args: dict[str, Any] | None,
) -> dict[str, Any]:
if not modified_args:
return {}
if not isinstance(original_args, dict) or not original_args:
return {}
allowed_keys = set(original_args.keys())
return {key: value for key, value in modified_args.items() if key in allowed_keys}
result: list[ChatMessage] = []
for msg in messages:
# Handle standard tool result messages early (role="tool") to preserve provider invariants
# This path maps AGUI tool messages to FunctionResultContent with the correct tool_call_id
role_str = normalize_agui_role(msg.get("role", "user"))
role_str = msg.get("role", "user")
if role_str == "tool":
# Prefer explicit tool_call_id fields; fall back to backend fields only if necessary
tool_call_id = msg.get("tool_call_id") or msg.get("toolCallId")
@@ -365,153 +57,31 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
result_content = msg.get("result", "")
# Distinguish approval payloads from actual tool results
parsed: dict[str, Any] | None = None
is_approval = False
if isinstance(result_content, str) and result_content:
try:
parsed_candidate = json.loads(result_content)
except Exception:
parsed_candidate = None
if isinstance(parsed_candidate, dict):
parsed = cast(dict[str, Any], parsed_candidate)
elif isinstance(result_content, dict):
parsed = cast(dict[str, Any], result_content)
import json as _json
is_approval = parsed is not None and "accepted" in parsed
try:
parsed = _json.loads(result_content)
is_approval = isinstance(parsed, dict) and "accepted" in parsed
except Exception:
is_approval = False
if is_approval:
# Look for the matching function call in previous messages to create
# a proper FunctionApprovalResponseContent. This enables the agent framework
# to execute the approved tool (fix for GitHub issue #3034).
accepted = parsed.get("accepted", False) if parsed is not None else False
approval_payload_text = result_content if isinstance(result_content, str) else json.dumps(parsed)
# Log the full approval payload to debug modified arguments
import logging
logger = logging.getLogger(__name__)
logger.info(f"Approval payload received: {parsed}")
approval_call_id = tool_call_id
resolved_call_id = _resolve_approval_call_id(tool_call_id, parsed)
if resolved_call_id:
approval_call_id = resolved_call_id
matching_func_call = _find_matching_func_call(approval_call_id)
if matching_func_call:
# Remove any existing tool result for this call_id since the framework
# will re-execute the tool after approval. Keeping old results causes
# OpenAI API errors ("tool message must follow assistant with tool_calls").
result = [
m
for m in result
if not (
(m.role.value if hasattr(m.role, "value") else str(m.role)) == "tool"
and any(
isinstance(c, FunctionResultContent) and c.call_id == approval_call_id
for c in (m.contents or [])
)
)
]
# Check if the approval payload contains modified arguments
# The UI sends back the modified state (e.g., deselected steps) in the approval payload
modified_args = {k: v for k, v in parsed.items() if k != "accepted"} if parsed else {}
original_args = matching_func_call.parse_arguments()
filtered_args = _filter_modified_args(modified_args, original_args)
state_args: dict[str, Any] | None = None
if filtered_args:
original_args = original_args or {}
merged_args: dict[str, Any]
if isinstance(original_args, dict) and original_args:
merged_args = {**original_args, **filtered_args}
else:
merged_args = dict(filtered_args)
if isinstance(filtered_args.get("steps"), list):
original_steps = original_args.get("steps") if isinstance(original_args, dict) else None
if isinstance(original_steps, list):
approved_steps_list = list(filtered_args.get("steps") or [])
approved_by_description: dict[str, dict[str, Any]] = {}
for step_item in approved_steps_list:
if isinstance(step_item, dict):
step_item_dict = cast(dict[str, Any], step_item)
desc = step_item_dict.get("description")
if desc:
approved_by_description[str(desc)] = step_item_dict
merged_steps: list[Any] = []
original_steps_list = cast(list[Any], original_steps)
for orig_step in original_steps_list:
if not isinstance(orig_step, dict):
merged_steps.append(orig_step)
continue
orig_step_dict = cast(dict[str, Any], orig_step)
description = str(orig_step_dict.get("description", ""))
approved_step = approved_by_description.get(description)
status: str = (
str(approved_step.get("status"))
if approved_step is not None and approved_step.get("status")
else "disabled"
)
updated_step: dict[str, Any] = orig_step_dict.copy()
updated_step["status"] = status
merged_steps.append(updated_step)
merged_args["steps"] = merged_steps
state_args = merged_args
# Keep the original tool call and AG-UI snapshot in sync with approved args.
updated_args = (
json.dumps(merged_args) if isinstance(matching_func_call.arguments, str) else merged_args
)
matching_func_call.arguments = updated_args
_update_tool_call_arguments(messages, str(approval_call_id), merged_args)
# Create a new FunctionCallContent with the modified arguments
func_call_for_approval = FunctionCallContent(
call_id=matching_func_call.call_id,
name=matching_func_call.name,
arguments=json.dumps(filtered_args),
)
logger.info(f"Using modified arguments from approval: {filtered_args}")
else:
# No modified arguments - use the original function call
func_call_for_approval = matching_func_call
# Create FunctionApprovalResponseContent for the agent framework
approval_response = FunctionApprovalResponseContent(
approved=accepted,
id=str(approval_call_id),
function_call=func_call_for_approval,
additional_properties={"ag_ui_state_args": state_args} if state_args else None,
)
chat_msg = ChatMessage(
role=Role.USER,
contents=[approval_response],
)
else:
# No matching function call found - this is likely a confirm_changes approval
# Keep the old behavior for backwards compatibility
chat_msg = ChatMessage(
role=Role.USER,
contents=[TextContent(text=approval_payload_text)],
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
)
# Approval responses should be treated as user messages to trigger human-in-the-loop flow
chat_msg = ChatMessage(
role=Role.USER,
contents=[TextContent(text=str(result_content))],
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
# Cast result_content to acceptable type for FunctionResultContent
func_result: str | dict[str, Any] | list[Any]
if isinstance(result_content, str):
func_result = result_content
elif isinstance(result_content, dict):
func_result = cast(dict[str, Any], result_content)
elif isinstance(result_content, list):
func_result = cast(list[Any], result_content)
else:
func_result = str(result_content)
chat_msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -572,7 +142,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# No special handling required for assistant/plain messages here
role = AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
role = _AGUI_TO_FRAMEWORK_ROLE.get(role_str, Role.USER)
# Check if this message contains function approvals
if "function_approvals" in msg and msg["function_approvals"]:
@@ -628,7 +198,6 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
if isinstance(msg, dict):
# Always work on a copy to avoid mutating input
normalized_msg = msg.copy()
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
# Ensure ID exists
if "id" not in normalized_msg:
normalized_msg["id"] = generate_event_id()
@@ -645,7 +214,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
continue
# Convert ChatMessage to AG-UI format
role = FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
role = _FRAMEWORK_TO_AGUI_ROLE.get(msg.role, "user")
content_text = ""
tool_calls: list[dict[str, Any]] = []
@@ -668,8 +237,13 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
elif isinstance(content, FunctionResultContent):
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
# Serialize result to string using core utility
content_text = prepare_function_call_results(content.result)
# Serialize result to string
if isinstance(content.result, dict):
import json
content_text = json.dumps(content.result) # type: ignore
elif content.result is not None:
content_text = str(content.result)
agui_msg: dict[str, Any] = {
"id": msg.message_id if msg.message_id else generate_event_id(), # Always include id
@@ -734,44 +308,22 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
content = normalized_msg.get("content")
if isinstance(content, list):
# Convert content array format to simple string
text_parts: list[str] = []
content_list = cast(list[Any], content)
for item in content_list:
text_parts = []
for item in content:
if isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
# Convert 'input_text' to 'text' type
if item_dict.get("type") == "input_text":
text_parts.append(str(item_dict.get("text", "")))
elif item_dict.get("type") == "text":
text_parts.append(str(item_dict.get("text", "")))
if item.get("type") == "input_text":
text_parts.append(item.get("text", ""))
elif item.get("type") == "text":
text_parts.append(item.get("text", ""))
else:
# Other types - just extract text field if present
text_parts.append(str(item_dict.get("text", "")))
text_parts.append(item.get("text", ""))
normalized_msg["content"] = "".join(text_parts)
elif content is None:
normalized_msg["content"] = ""
tool_calls = normalized_msg.get("tool_calls") or normalized_msg.get("toolCalls")
if isinstance(tool_calls, list):
tool_calls_list = cast(list[Any], tool_calls)
for tool_call in tool_calls_list:
if not isinstance(tool_call, dict):
continue
tool_call_dict = cast(dict[str, Any], tool_call)
function_payload = tool_call_dict.get("function")
if not isinstance(function_payload, dict):
continue
function_payload_dict = cast(dict[str, Any], function_payload)
if "arguments" not in function_payload_dict:
continue
arguments = function_payload_dict.get("arguments")
if arguments is None:
function_payload_dict["arguments"] = ""
elif not isinstance(arguments, str):
function_payload_dict["arguments"] = json.dumps(arguments)
# Normalize tool_call_id to toolCallId for tool messages
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
if normalized_msg.get("role") == "tool":
if "tool_call_id" in normalized_msg:
normalized_msg["toolCallId"] = normalized_msg["tool_call_id"]
@@ -1,391 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Helper functions for orchestration logic."""
import json
import logging
from typing import TYPE_CHECKING, Any
from ag_ui.core import StateSnapshotEvent
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
)
from .._utils import get_role_value, safe_json_parse
if TYPE_CHECKING:
from .._events import AgentFrameworkEventBridge
from ._state_manager import StateManager
logger = logging.getLogger(__name__)
def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
"""Get IDs of tool calls without corresponding results.
Args:
messages: List of messages to scan
Returns:
Set of pending tool call IDs
"""
pending_ids: set[str] = set()
resolved_ids: set[str] = set()
for msg in messages:
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
pending_ids.add(str(content.call_id))
elif isinstance(content, FunctionResultContent) and content.call_id:
resolved_ids.add(str(content.call_id))
return pending_ids - resolved_ids
def is_state_context_message(message: ChatMessage) -> bool:
"""Check if a message is a state context system message.
Args:
message: Message to check
Returns:
True if this is a state context message
"""
if get_role_value(message) != "system":
return False
for content in message.contents:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
return True
return False
def ensure_tool_call_entry(
tool_call_id: str,
tool_calls_by_id: dict[str, dict[str, Any]],
pending_tool_calls: list[dict[str, Any]],
) -> dict[str, Any]:
"""Get or create a tool call entry in the tracking dicts.
Args:
tool_call_id: The tool call ID
tool_calls_by_id: Dict mapping IDs to tool call entries
pending_tool_calls: List of pending tool calls
Returns:
The tool call entry dict
"""
entry = tool_calls_by_id.get(tool_call_id)
if entry is None:
entry = {
"id": tool_call_id,
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
tool_calls_by_id[tool_call_id] = entry
pending_tool_calls.append(entry)
return entry
def tool_name_for_call_id(
tool_calls_by_id: dict[str, dict[str, Any]],
tool_call_id: str,
) -> str | None:
"""Get the tool name for a given call ID.
Args:
tool_calls_by_id: Dict mapping IDs to tool call entries
tool_call_id: The tool call ID to look up
Returns:
Tool name or None if not found
"""
entry = tool_calls_by_id.get(tool_call_id)
if not entry:
return None
function = entry.get("function")
if not isinstance(function, dict):
return None
name = function.get("name")
return str(name) if name else None
def tool_calls_match_state(
provider_messages: list[ChatMessage],
state_manager: "StateManager",
) -> bool:
"""Check if tool calls in messages match current state.
Args:
provider_messages: Messages to check
state_manager: State manager with config and current state
Returns:
True if tool calls match state configuration
"""
if not state_manager.predict_state_config or not state_manager.current_state:
return False
for state_key, config in state_manager.predict_state_config.items():
tool_name = config["tool"]
tool_arg_name = config["tool_argument"]
tool_args: dict[str, Any] | None = None
for msg in reversed(provider_messages):
if get_role_value(msg) != "assistant":
continue
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.name == tool_name:
tool_args = safe_json_parse(content.arguments)
break
if tool_args is not None:
break
if not tool_args:
return False
if tool_arg_name == "*":
state_value = tool_args
elif tool_arg_name in tool_args:
state_value = tool_args[tool_arg_name]
else:
return False
if state_manager.current_state.get(state_key) != state_value:
return False
return True
def schema_has_steps(schema: Any) -> bool:
"""Check if a schema has a steps array property.
Args:
schema: JSON schema to check
Returns:
True if schema has steps array
"""
if not isinstance(schema, dict):
return False
properties = schema.get("properties")
if not isinstance(properties, dict):
return False
steps_schema = properties.get("steps")
if not isinstance(steps_schema, dict):
return False
return steps_schema.get("type") == "array"
def select_approval_tool_name(client_tools: list[Any] | None) -> str | None:
"""Select appropriate approval tool from client tools.
Args:
client_tools: List of client tool definitions
Returns:
Name of approval tool, or None if not found
"""
if not client_tools:
return None
for tool in client_tools:
tool_name = getattr(tool, "name", None)
if not tool_name:
continue
params_fn = getattr(tool, "parameters", None)
if not callable(params_fn):
continue
schema = params_fn()
if schema_has_steps(schema):
return str(tool_name)
return None
def select_messages_to_run(
provider_messages: list[ChatMessage],
state_manager: "StateManager",
) -> list[ChatMessage]:
"""Select and prepare messages for agent execution.
Injects state context message when appropriate.
Args:
provider_messages: Original messages from client
state_manager: State manager instance
Returns:
Messages ready for agent execution
"""
if not provider_messages:
return []
is_new_user_turn = get_role_value(provider_messages[-1]) == "user"
conversation_has_tool_calls = tool_calls_match_state(provider_messages, state_manager)
state_context_msg = state_manager.state_context_message(
is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls
)
if not state_context_msg:
return list(provider_messages)
messages_to_run = [msg for msg in provider_messages if not is_state_context_message(msg)]
if pending_tool_call_ids(messages_to_run):
return messages_to_run
insert_index = len(messages_to_run) - 1 if is_new_user_turn else len(messages_to_run)
if insert_index < 0:
insert_index = 0
messages_to_run.insert(insert_index, state_context_msg)
return messages_to_run
def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Build metadata dict with truncated string values.
Args:
thread_metadata: Raw metadata dict
Returns:
Metadata with string values truncated to 512 chars
"""
if not thread_metadata:
return {}
safe_metadata: dict[str, Any] = {}
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
safe_metadata[key] = value_str
return safe_metadata
def collect_approved_state_snapshots(
provider_messages: list[ChatMessage],
predict_state_config: dict[str, dict[str, str]] | None,
current_state: dict[str, Any],
event_bridge: "AgentFrameworkEventBridge",
) -> list[StateSnapshotEvent]:
"""Collect state snapshots from approved function calls.
Args:
provider_messages: Messages containing approvals
predict_state_config: Predictive state configuration
current_state: Current state dict (will be mutated)
event_bridge: Event bridge for creating events
Returns:
List of state snapshot events
"""
if not predict_state_config:
return []
events: list[StateSnapshotEvent] = []
for msg in provider_messages:
if get_role_value(msg) != "user":
continue
for content in msg.contents:
if type(content) is FunctionApprovalResponseContent:
if not content.function_call or not content.approved:
continue
parsed_args = content.function_call.parse_arguments()
state_args = None
if content.additional_properties:
state_args = content.additional_properties.get("ag_ui_state_args")
if not isinstance(state_args, dict):
state_args = parsed_args
if not state_args:
continue
for state_key, config in predict_state_config.items():
if config["tool"] != content.function_call.name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
state_value = state_args
elif isinstance(state_args, dict) and tool_arg_name in state_args:
state_value = state_args[tool_arg_name]
else:
continue
current_state[state_key] = state_value
event_bridge.current_state[state_key] = state_value
logger.info(
f"Emitting StateSnapshotEvent for approved state key '{state_key}' "
f"with {len(state_value) if isinstance(state_value, list) else 'N/A'} items"
)
events.append(StateSnapshotEvent(snapshot=current_state))
break
return events
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
"""Get the latest approval response from messages.
Args:
messages: Messages to search
Returns:
Latest approval response or None
"""
if not messages:
return None
last_message = messages[-1]
for content in last_message.contents:
if type(content) is FunctionApprovalResponseContent:
return content
return None
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
"""Extract steps from an approval response.
Args:
approval: Approval response content
Returns:
List of steps, or empty list if none
"""
state_args: Any | None = None
if approval.additional_properties:
state_args = approval.additional_properties.get("ag_ui_state_args")
if isinstance(state_args, dict):
steps = state_args.get("steps")
if isinstance(steps, list):
return steps
if approval.function_call:
parsed_args = approval.function_call.parse_arguments()
if isinstance(parsed_args, dict):
steps = parsed_args.get("steps")
if isinstance(steps, list):
return steps
return []
def is_step_based_approval(
approval: FunctionApprovalResponseContent,
predict_state_config: dict[str, dict[str, str]] | None,
) -> bool:
"""Check if an approval is step-based.
Args:
approval: Approval response to check
predict_state_config: Predictive state configuration
Returns:
True if this is a step-based approval
"""
steps = approval_steps(approval)
if steps:
return True
if not approval.function_call:
return False
if not predict_state_config:
return False
tool_name = approval.function_call.name
for config in predict_state_config.values():
if config.get("tool") == tool_name and config.get("tool_argument") == "steps":
return True
return False
@@ -0,0 +1,176 @@
# Copyright (c) Microsoft. All rights reserved.
"""Message hygiene utilities for orchestrators."""
import json
import logging
from typing import Any
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
logger = logging.getLogger(__name__)
def sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
"""Normalize tool ordering and inject synthetic results for AG-UI edge cases."""
sanitized: list[ChatMessage] = []
pending_tool_call_ids: set[str] | None = None
pending_confirm_changes_id: str | None = None
for msg in messages:
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
if role_value == "assistant":
tool_ids = {
str(content.call_id)
for content in msg.contents or []
if isinstance(content, FunctionCallContent) and content.call_id
}
confirm_changes_call = None
for content in msg.contents or []:
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
confirm_changes_call = content
break
sanitized.append(msg)
pending_tool_call_ids = tool_ids if tool_ids else None
pending_confirm_changes_id = (
str(confirm_changes_call.call_id) if confirm_changes_call and confirm_changes_call.call_id else None
)
continue
if role_value == "user":
if pending_confirm_changes_id:
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
break
try:
parsed = json.loads(user_text)
if "accepted" in parsed:
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
)
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
)
],
)
sanitized.append(synthetic_result)
if pending_tool_call_ids:
pending_tool_call_ids.discard(pending_confirm_changes_id)
pending_confirm_changes_id = None
continue
except (json.JSONDecodeError, KeyError) as exc:
logger.debug("Could not parse user message as confirm_changes response: %s", type(exc).__name__)
if pending_tool_call_ids:
logger.info(
f"User message arrived with {len(pending_tool_call_ids)} pending tool calls - injecting synthetic results"
)
for pending_call_id in pending_tool_call_ids:
logger.info(f"Injecting synthetic tool result for pending call_id={pending_call_id}")
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
call_id=pending_call_id,
result="Tool execution skipped - user provided follow-up message",
)
],
)
sanitized.append(synthetic_result)
pending_tool_call_ids = None
pending_confirm_changes_id = None
sanitized.append(msg)
pending_confirm_changes_id = None
continue
if role_value == "tool":
if not pending_tool_call_ids:
continue
keep = False
for content in msg.contents or []:
if isinstance(content, FunctionResultContent):
call_id = str(content.call_id)
if call_id in pending_tool_call_ids:
keep = True
if call_id == pending_confirm_changes_id:
pending_confirm_changes_id = None
break
if keep:
sanitized.append(msg)
continue
sanitized.append(msg)
pending_tool_call_ids = None
pending_confirm_changes_id = None
return sanitized
def deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
"""Remove duplicate messages while preserving order."""
seen_keys: dict[Any, int] = {}
unique_messages: list[ChatMessage] = []
for idx, msg in enumerate(messages):
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
call_id = str(msg.contents[0].call_id)
key: Any = (role_value, call_id)
if key in seen_keys:
existing_idx = seen_keys[key]
existing_msg = unique_messages[existing_idx]
existing_result = None
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
existing_result = existing_msg.contents[0].result
new_result = msg.contents[0].result
if (not existing_result or existing_result == "") and new_result:
logger.info(f"Replacing empty tool result at index {existing_idx} with data from index {idx}")
unique_messages[existing_idx] = msg
else:
logger.info(f"Skipping duplicate tool result at index {idx}: call_id={call_id}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
elif (
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
):
tool_call_ids = tuple(
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
)
key = (role_value, tool_call_ids)
if key in seen_keys:
logger.info(f"Skipping duplicate assistant tool call at index {idx}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
else:
content_str = str([str(c) for c in msg.contents]) if msg.contents else ""
key = (role_value, hash(content_str))
if key in seen_keys:
logger.info(f"Skipping duplicate message at index {idx}: role={role_value}")
continue
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
return unique_messages
@@ -1,230 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Predictive state handling utilities."""
import json
import logging
import re
from typing import Any
from ag_ui.core import StateDeltaEvent
from .._utils import safe_json_parse
logger = logging.getLogger(__name__)
class PredictiveStateHandler:
"""Handles predictive state updates from streaming tool calls."""
def __init__(
self,
predict_state_config: dict[str, dict[str, str]] | None = None,
current_state: dict[str, Any] | None = None,
) -> None:
"""Initialize the handler.
Args:
predict_state_config: Configuration mapping state keys to tool/argument pairs
current_state: Reference to current state dict
"""
self.predict_state_config = predict_state_config or {}
self.current_state = current_state or {}
self.streaming_tool_args: str = ""
self.last_emitted_state: dict[str, Any] = {}
self.state_delta_count: int = 0
self.pending_state_updates: dict[str, Any] = {}
def reset_streaming(self) -> None:
"""Reset streaming state for a new tool call."""
self.streaming_tool_args = ""
self.state_delta_count = 0
def extract_state_value(
self,
tool_name: str,
args: dict[str, Any] | str | None,
) -> tuple[str, Any] | None:
"""Extract state value from tool arguments based on config.
Args:
tool_name: Name of the tool being called
args: Tool arguments (dict or JSON string)
Returns:
Tuple of (state_key, state_value) or None if no match
"""
if not self.predict_state_config:
return None
parsed_args = safe_json_parse(args) if isinstance(args, str) else args
if not parsed_args:
return None
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
return (state_key, parsed_args)
if tool_arg_name in parsed_args:
return (state_key, parsed_args[tool_arg_name])
return None
def is_predictive_tool(self, tool_name: str | None) -> bool:
"""Check if a tool is configured for predictive state.
Args:
tool_name: Name of the tool to check
Returns:
True if tool is in predictive state config
"""
if not tool_name or not self.predict_state_config:
return False
for config in self.predict_state_config.values():
if config["tool"] == tool_name:
return True
return False
def emit_streaming_deltas(
self,
tool_name: str | None,
argument_chunk: str,
) -> list[StateDeltaEvent]:
"""Process streaming argument chunk and emit state deltas.
Args:
tool_name: Name of the current tool
argument_chunk: New chunk of JSON arguments
Returns:
List of state delta events to emit
"""
events: list[StateDeltaEvent] = []
if not tool_name or not self.predict_state_config:
return events
self.streaming_tool_args += argument_chunk
logger.debug(
"Predictive state: accumulated %s chars for tool '%s'",
len(self.streaming_tool_args),
tool_name,
)
# Try to parse complete JSON first
parsed_args = None
try:
parsed_args = json.loads(self.streaming_tool_args)
except json.JSONDecodeError:
# Fall back to regex matching for partial JSON
events.extend(self._emit_partial_deltas(tool_name))
if parsed_args:
events.extend(self._emit_complete_deltas(tool_name, parsed_args))
return events
def _emit_partial_deltas(self, tool_name: str) -> list[StateDeltaEvent]:
"""Emit deltas from partial JSON using regex matching.
Args:
tool_name: Name of the current tool
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
pattern = rf'"{re.escape(tool_arg_name)}":\s*"([^"]*)'
match = re.search(pattern, self.streaming_tool_args)
if match:
partial_value = match.group(1).replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != partial_value:
event = self._create_delta_event(state_key, partial_value)
events.append(event)
self.last_emitted_state[state_key] = partial_value
self.pending_state_updates[state_key] = partial_value
return events
def _emit_complete_deltas(
self,
tool_name: str,
parsed_args: dict[str, Any],
) -> list[StateDeltaEvent]:
"""Emit deltas from complete parsed JSON.
Args:
tool_name: Name of the current tool
parsed_args: Fully parsed arguments dict
Returns:
List of state delta events
"""
events: list[StateDeltaEvent] = []
for state_key, config in self.predict_state_config.items():
if config["tool"] != tool_name:
continue
tool_arg_name = config["tool_argument"]
if tool_arg_name == "*":
state_value = parsed_args
elif tool_arg_name in parsed_args:
state_value = parsed_args[tool_arg_name]
else:
continue
if state_key not in self.last_emitted_state or self.last_emitted_state[state_key] != state_value:
event = self._create_delta_event(state_key, state_value)
events.append(event)
self.last_emitted_state[state_key] = state_value
self.pending_state_updates[state_key] = state_value
return events
def _create_delta_event(self, state_key: str, value: Any) -> StateDeltaEvent:
"""Create a state delta event with logging.
Args:
state_key: The state key being updated
value: The new value
Returns:
StateDeltaEvent instance
"""
self.state_delta_count += 1
if self.state_delta_count % 10 == 1:
logger.info(
"StateDeltaEvent #%s for '%s': op=replace, path=/%s, value_length=%s",
self.state_delta_count,
state_key,
state_key,
len(str(value)),
)
elif self.state_delta_count % 100 == 0:
logger.info(f"StateDeltaEvent #{self.state_delta_count} emitted")
return StateDeltaEvent(
delta=[
{
"op": "replace",
"path": f"/{state_key}",
"value": value,
}
],
)
def apply_pending_updates(self) -> None:
"""Apply pending updates to current state and clear them."""
for key, value in self.pending_state_updates.items():
self.current_state[key] = value
self.pending_state_updates.clear()
@@ -22,11 +22,9 @@ class StateManager:
self.predict_state_config = predict_state_config or {}
self.require_confirmation = require_confirmation
self.current_state: dict[str, Any] = {}
self._state_from_input: bool = False
def initialize(self, initial_state: dict[str, Any] | None) -> dict[str, Any]:
"""Initialize state with schema defaults."""
self._state_from_input = initial_state is not None
self.current_state = (initial_state or {}).copy()
self._apply_schema_defaults()
return self.current_state
@@ -62,9 +60,7 @@ class StateManager:
"""Inject state context only when starting a new user turn."""
if not self.current_state or not self.state_schema:
return None
if not is_new_user_turn:
return None
if conversation_has_tool_calls and not self._state_from_input:
if not is_new_user_turn or conversation_has_tool_calls:
return None
state_json = json.dumps(self.current_state, indent=2)
@@ -16,10 +16,6 @@ from ag_ui.core import (
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import (
AgentProtocol,
@@ -29,31 +25,8 @@ from agent_framework import (
FunctionResultContent,
TextContent,
)
from agent_framework._middleware import extract_and_merge_function_middleware
from agent_framework._tools import (
FunctionInvocationConfiguration,
_collect_approval_responses, # type: ignore
_replace_approval_contents_with_results, # type: ignore
_try_execute_function_calls, # type: ignore
)
from ._orchestration._helpers import (
approval_steps,
build_safe_metadata,
collect_approved_state_snapshots,
ensure_tool_call_entry,
is_step_based_approval,
latest_approval_response,
select_approval_tool_name,
select_messages_to_run,
tool_name_for_call_id,
)
from ._orchestration._tooling import (
collect_server_tools,
merge_tools,
register_additional_client_tools,
)
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id, get_role_value
from ._utils import convert_agui_tools_to_agent_framework, generate_event_id
if TYPE_CHECKING:
from ._agent import AgentConfig
@@ -88,7 +61,6 @@ class ExecutionContext:
# Lazy-loaded properties
self._messages = None
self._snapshot_messages = None
self._last_message = None
self._run_id: str | None = None
self._thread_id: str | None = None
@@ -97,27 +69,12 @@ class ExecutionContext:
def messages(self):
"""Get converted Agent Framework messages (lazy loaded)."""
if self._messages is None:
from ._message_adapters import normalize_agui_input_messages
from ._message_adapters import agui_messages_to_agent_framework
raw = self.input_data.get("messages", [])
if not isinstance(raw, list):
raw = []
self._messages, self._snapshot_messages = normalize_agui_input_messages(raw)
self._messages = agui_messages_to_agent_framework(raw)
return self._messages
@property
def snapshot_messages(self) -> list[dict[str, Any]]:
"""Get normalized AG-UI snapshot messages (lazy loaded)."""
if self._snapshot_messages is None:
if self._messages is None:
_ = self.messages
else:
from ._message_adapters import agent_framework_messages_to_agui, agui_messages_to_snapshot_format
raw_snapshot = agent_framework_messages_to_agui(self._messages)
self._snapshot_messages = agui_messages_to_snapshot_format(raw_snapshot)
return self._snapshot_messages or []
@property
def last_message(self):
"""Get the last message in the conversation (lazy loaded)."""
@@ -313,7 +270,14 @@ class DefaultOrchestrator(Orchestrator):
AG-UI events
"""
from ._events import AgentFrameworkEventBridge
from ._message_adapters import agui_messages_to_snapshot_format
from ._orchestration._message_hygiene import deduplicate_messages, sanitize_tool_history
from ._orchestration._state_manager import StateManager
from ._orchestration._tooling import (
collect_server_tools,
merge_tools,
register_additional_client_tools,
)
logger.info(f"Starting default agent run for thread_id={context.thread_id}, run_id={context.run_id}")
@@ -322,15 +286,12 @@ class DefaultOrchestrator(Orchestrator):
response_format = context.agent.chat_options.response_format
skip_text_content = response_format is not None
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
approval_tool_name = select_approval_tool_name(client_tools)
state_manager = StateManager(
state_schema=context.config.state_schema,
predict_state_config=context.config.predict_state_config,
require_confirmation=context.config.require_confirmation,
)
current_state = state_manager.initialize(context.input_data.get("state"))
current_state = state_manager.initialize(context.input_data.get("state", {}))
event_bridge = AgentFrameworkEventBridge(
run_id=context.run_id,
@@ -338,8 +299,8 @@ class DefaultOrchestrator(Orchestrator):
predict_state_config=context.config.predict_state_config,
current_state=current_state,
skip_text_content=skip_text_content,
input_messages=context.input_data.get("messages", []),
require_confirmation=context.config.require_confirmation,
approval_tool_name=approval_tool_name,
)
yield event_bridge.create_run_started_event()
@@ -360,18 +321,17 @@ class DefaultOrchestrator(Orchestrator):
if current_state:
thread.metadata["current_state"] = current_state # type: ignore[attr-defined]
provider_messages = context.messages or []
snapshot_messages = context.snapshot_messages
if not provider_messages:
raw_messages = context.messages or []
if not raw_messages:
logger.warning("No messages provided in AG-UI input")
yield event_bridge.create_run_finished_event()
return
logger.info(f"Received {len(provider_messages)} provider messages from client")
for i, msg in enumerate(provider_messages):
role = get_role_value(msg)
logger.info(f"Received {len(raw_messages)} raw messages from client")
for i, msg in enumerate(raw_messages):
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
msg_id = getattr(msg, "message_id", None)
logger.info(f" Message {i}: role={role}, id={msg_id}")
logger.info(f" Raw message {i}: role={role}, id={msg_id}")
if hasattr(msg, "contents") and msg.contents:
for j, content in enumerate(msg.contents):
content_type = type(content).__name__
@@ -394,26 +354,62 @@ class DefaultOrchestrator(Orchestrator):
else:
logger.debug(f" Content {j}: {content_type}")
pending_tool_calls: list[dict[str, Any]] = []
tool_calls_by_id: dict[str, dict[str, Any]] = {}
tool_results: list[dict[str, Any]] = []
tool_calls_ended: set[str] = set()
messages_snapshot_emitted = False
accumulated_text_content = ""
active_message_id: str | None = None
sanitized_messages = sanitize_tool_history(raw_messages)
provider_messages = deduplicate_messages(sanitized_messages)
# Check for FunctionApprovalResponseContent and emit updated state snapshot
# This ensures the UI shows the approved state (e.g., 2 steps) not the original (3 steps)
for snapshot_evt in collect_approved_state_snapshots(
provider_messages,
context.config.predict_state_config,
current_state,
event_bridge,
):
yield snapshot_evt
if not provider_messages:
logger.info("No provider-eligible messages after filtering; finishing run without invoking agent.")
yield event_bridge.create_run_finished_event()
return
messages_to_run = select_messages_to_run(provider_messages, state_manager)
logger.info(f"Processing {len(provider_messages)} provider messages after sanitization/deduplication")
for i, msg in enumerate(provider_messages):
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
logger.info(f" Message {i}: role={role}")
if hasattr(msg, "contents") and msg.contents:
for j, content in enumerate(msg.contents):
content_type = type(content).__name__
if isinstance(content, TextContent):
logger.info(f" Content {j}: {content_type} - text_length={len(content.text)}")
elif isinstance(content, FunctionCallContent):
arg_length = len(str(content.arguments)) if content.arguments else 0
logger.info(" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length)
elif isinstance(content, FunctionResultContent):
result_preview = type(content.result).__name__ if content.result is not None else "None"
logger.info(
" Content %s: %s - call_id=%s, result_type=%s",
j,
content_type,
content.call_id,
result_preview,
)
else:
logger.info(f" Content {j}: {content_type}")
messages_to_run: list[Any] = []
is_new_user_turn = False
if provider_messages:
last_msg = provider_messages[-1]
role_value = last_msg.role.value if hasattr(last_msg.role, "value") else str(last_msg.role)
is_new_user_turn = role_value == "user"
conversation_has_tool_calls = False
for msg in provider_messages:
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
if role_value == "assistant" and hasattr(msg, "contents") and msg.contents:
if any(isinstance(content, FunctionCallContent) for content in msg.contents):
conversation_has_tool_calls = True
break
state_context_msg = state_manager.state_context_message(
is_new_user_turn=is_new_user_turn, conversation_has_tool_calls=conversation_has_tool_calls
)
if state_context_msg:
messages_to_run.append(state_context_msg)
messages_to_run.extend(provider_messages)
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
logger.info(f"[TOOLS] Client sent {len(client_tools) if client_tools else 0} tools")
if client_tools:
for tool in client_tools:
@@ -425,11 +421,17 @@ class DefaultOrchestrator(Orchestrator):
register_additional_client_tools(context.agent, client_tools)
tools_param = merge_tools(server_tools, client_tools)
collect_updates = response_format is not None
all_updates: list[Any] | None = [] if collect_updates else None
all_updates: list[Any] = []
update_count = 0
# Prepare metadata for chat client (Azure requires string values)
safe_metadata = build_safe_metadata(getattr(thread, "metadata", None))
safe_metadata: dict[str, Any] = {}
thread_metadata = getattr(thread, "metadata", None)
if thread_metadata:
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
safe_metadata[key] = value_str
run_kwargs: dict[str, Any] = {
"thread": thread,
@@ -439,200 +441,27 @@ class DefaultOrchestrator(Orchestrator):
if safe_metadata:
run_kwargs["store"] = True
async def _resolve_approval_responses(
messages: list[Any],
tools_for_execution: list[Any],
) -> None:
fcc_todo = _collect_approval_responses(messages)
if not fcc_todo:
return
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Any] = []
if approved_responses and tools_for_execution:
chat_client = getattr(context.agent, "chat_client", None)
config = (
getattr(chat_client, "function_invocation_configuration", None) or FunctionInvocationConfiguration()
)
middleware_pipeline = extract_and_merge_function_middleware(chat_client, run_kwargs)
try:
results, _ = await _try_execute_function_calls(
custom_args=run_kwargs,
attempt_idx=0,
function_calls=approved_responses,
tools=tools_for_execution,
middleware_pipeline=middleware_pipeline,
config=config,
)
approved_function_results = list(results)
except Exception:
logger.error("Failed to execute approved tool calls; injecting error results.")
approved_function_results = []
normalized_results: list[FunctionResultContent] = []
for idx, approval in enumerate(approved_responses):
if idx < len(approved_function_results) and isinstance(
approved_function_results[idx], FunctionResultContent
):
normalized_results.append(approved_function_results[idx])
continue
call_id = approval.function_call.call_id or approval.id
normalized_results.append(
FunctionResultContent(call_id=call_id, result="Error: Tool call invocation failed.")
)
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
def _should_emit_tool_snapshot(tool_name: str | None) -> bool:
if not pending_tool_calls or not tool_results:
return False
if tool_name and context.config.predict_state_config and not context.config.require_confirmation:
for config in context.config.predict_state_config.values():
if config["tool"] == tool_name:
logger.info(
f"Skipping intermediate MessagesSnapshotEvent for predictive tool '{tool_name}' "
" - delaying until summary"
)
return False
return True
def _build_messages_snapshot(tool_message_id: str | None = None) -> MessagesSnapshotEvent:
has_text_content = bool(accumulated_text_content)
all_messages = snapshot_messages.copy()
if pending_tool_calls:
if tool_message_id and not has_text_content:
tool_call_message_id = tool_message_id
else:
tool_call_message_id = (
active_message_id if not has_text_content and active_message_id else generate_event_id()
)
tool_call_message = {
"id": tool_call_message_id,
"role": "assistant",
"tool_calls": pending_tool_calls.copy(),
}
all_messages.append(tool_call_message)
all_messages.extend(tool_results)
if has_text_content and active_message_id:
assistant_text_message = {
"id": active_message_id,
"role": "assistant",
"content": accumulated_text_content,
}
all_messages.append(assistant_text_message)
return MessagesSnapshotEvent(
messages=all_messages, # type: ignore[arg-type]
)
# Use tools_param if available (includes client tools), otherwise fall back to server_tools
# This ensures both server tools AND client tools can be executed after approval
tools_for_approval = tools_param if tools_param is not None else server_tools
latest_approval = latest_approval_response(messages_to_run)
await _resolve_approval_responses(messages_to_run, tools_for_approval)
if latest_approval and is_step_based_approval(latest_approval, context.config.predict_state_config):
from ._confirmation_strategies import DefaultConfirmationStrategy
strategy = context.confirmation_strategy
if strategy is None:
strategy = DefaultConfirmationStrategy()
steps = approval_steps(latest_approval)
if steps:
if latest_approval.approved:
confirmation_message = strategy.on_approval_accepted(steps)
else:
confirmation_message = strategy.on_approval_rejected(steps)
else:
if latest_approval.approved:
confirmation_message = strategy.on_state_confirmed()
else:
confirmation_message = strategy.on_state_rejected()
message_id = generate_event_id()
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=confirmation_message)
yield TextMessageEndEvent(message_id=message_id)
yield event_bridge.create_run_finished_event()
return
async for update in context.agent.run_stream(messages_to_run, **run_kwargs):
update_count += 1
logger.info(f"[STREAM] Received update #{update_count} from agent")
if all_updates is not None:
all_updates.append(update)
if event_bridge.current_message_id is None and update.contents:
has_tool_call = any(isinstance(content, FunctionCallContent) for content in update.contents)
has_text = any(isinstance(content, TextContent) for content in update.contents)
if has_tool_call and not has_text:
tool_message_id = generate_event_id()
event_bridge.current_message_id = tool_message_id
active_message_id = tool_message_id
accumulated_text_content = ""
logger.info(
"[STREAM] Emitting TextMessageStartEvent for tool-only response message_id=%s",
tool_message_id,
)
yield TextMessageStartEvent(message_id=tool_message_id, role="assistant")
all_updates.append(update)
events = await event_bridge.from_agent_run_update(update)
logger.info(f"[STREAM] Update #{update_count} produced {len(events)} events")
for event in events:
if isinstance(event, TextMessageStartEvent):
active_message_id = event.message_id
accumulated_text_content = ""
elif isinstance(event, TextMessageContentEvent):
accumulated_text_content += event.delta
elif isinstance(event, ToolCallStartEvent):
tool_call_entry = ensure_tool_call_entry(event.tool_call_id, tool_calls_by_id, pending_tool_calls)
tool_call_entry["function"]["name"] = event.tool_call_name
elif isinstance(event, ToolCallArgsEvent):
tool_call_entry = ensure_tool_call_entry(event.tool_call_id, tool_calls_by_id, pending_tool_calls)
tool_call_entry["function"]["arguments"] += event.delta
elif isinstance(event, ToolCallEndEvent):
tool_calls_ended.add(event.tool_call_id)
elif isinstance(event, ToolCallResultEvent):
tool_results.append(
{
"id": event.message_id,
"role": "tool",
"toolCallId": event.tool_call_id,
"content": event.content,
}
)
logger.info(f"[STREAM] Yielding event: {type(event).__name__}")
yield event
if isinstance(event, ToolCallResultEvent):
tool_name = tool_name_for_call_id(tool_calls_by_id, event.tool_call_id)
if _should_emit_tool_snapshot(tool_name):
messages_snapshot_emitted = True
messages_snapshot = _build_messages_snapshot()
logger.info(f"[STREAM] Yielding event: {type(messages_snapshot).__name__}")
yield messages_snapshot
elif isinstance(event, ToolCallEndEvent):
tool_name = tool_name_for_call_id(tool_calls_by_id, event.tool_call_id)
if tool_name == "confirm_changes":
messages_snapshot_emitted = True
messages_snapshot = _build_messages_snapshot()
logger.info(f"[STREAM] Yielding event: {type(messages_snapshot).__name__}")
yield messages_snapshot
logger.info(f"[STREAM] Agent stream completed. Total updates: {update_count}")
if event_bridge.should_stop_after_confirm:
logger.info("Stopping run - waiting for user approval/confirmation response")
if event_bridge.current_message_id:
logger.info(f"[CONFIRM] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
event_bridge.current_message_id = None
logger.info("Stopping run after confirm_changes - waiting for user response")
yield event_bridge.create_run_finished_event()
return
if pending_tool_calls:
pending_without_end = [tc for tc in pending_tool_calls if tc.get("id") not in tool_calls_ended]
if event_bridge.pending_tool_calls:
pending_without_end = [
tc for tc in event_bridge.pending_tool_calls if tc.get("id") not in event_bridge.tool_calls_ended
]
if pending_without_end:
logger.info(
"Found %s pending tool calls without end event - emitting ToolCallEndEvent",
@@ -641,11 +470,13 @@ class DefaultOrchestrator(Orchestrator):
for tool_call in pending_without_end:
tool_call_id = tool_call.get("id")
if tool_call_id:
from ag_ui.core import ToolCallEndEvent
end_event = ToolCallEndEvent(tool_call_id=tool_call_id)
logger.info(f"Emitting ToolCallEndEvent for declaration-only tool call '{tool_call_id}'")
yield end_event
if response_format and all_updates:
if all_updates and response_format:
from agent_framework import AgentRunResponse
from pydantic import BaseModel
@@ -677,22 +508,37 @@ class DefaultOrchestrator(Orchestrator):
logger.info(f"[FINALIZE] Emitting TextMessageEndEvent for message_id={event_bridge.current_message_id}")
yield event_bridge.create_message_end_event(event_bridge.current_message_id)
messages_snapshot = _build_messages_snapshot(tool_message_id=event_bridge.current_message_id)
messages_snapshot_emitted = True
assistant_text_message = {
"id": event_bridge.current_message_id,
"role": "assistant",
"content": event_bridge.accumulated_text_content,
}
converted_input_messages = agui_messages_to_snapshot_format(event_bridge.input_messages)
all_messages = converted_input_messages.copy()
if event_bridge.pending_tool_calls:
tool_call_message = {
"id": generate_event_id(),
"role": "assistant",
"tool_calls": event_bridge.pending_tool_calls.copy(),
}
all_messages.append(tool_call_message)
all_messages.extend(event_bridge.tool_results.copy())
all_messages.append(assistant_text_message)
messages_snapshot = MessagesSnapshotEvent(
messages=all_messages, # type: ignore[arg-type]
)
logger.info(
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(messages_snapshot.messages)} messages "
f"(text content length: {len(accumulated_text_content)})"
"[FINALIZE] Emitting MessagesSnapshotEvent with %s messages (text content length: %s)",
len(all_messages),
len(event_bridge.accumulated_text_content),
)
yield messages_snapshot
else:
logger.info("[FINALIZE] No current_message_id - skipping TextMessageEndEvent")
if not messages_snapshot_emitted and (pending_tool_calls or tool_results):
messages_snapshot = _build_messages_snapshot()
messages_snapshot_emitted = True
logger.info(
f"[FINALIZE] Emitting MessagesSnapshotEvent with {len(messages_snapshot.messages)} messages"
)
yield messages_snapshot
logger.info("[FINALIZE] Emitting RUN_FINISHED event")
yield event_bridge.create_run_finished_event()
@@ -3,29 +3,13 @@
"""Utility functions for AG-UI integration."""
import copy
import json
import uuid
from collections.abc import Callable, MutableMapping, Sequence
from dataclasses import asdict, is_dataclass
from datetime import date, datetime
from typing import Any
from agent_framework import AIFunction, Role, ToolProtocol
# Role mapping constants
AGUI_TO_FRAMEWORK_ROLE: dict[str, Role] = {
"user": Role.USER,
"assistant": Role.ASSISTANT,
"system": Role.SYSTEM,
}
FRAMEWORK_TO_AGUI_ROLE: dict[Role, str] = {
Role.USER: "user",
Role.ASSISTANT: "assistant",
Role.SYSTEM: "system",
}
ALLOWED_AGUI_ROLES: set[str] = {"user", "assistant", "system", "tool"}
from agent_framework import AIFunction, ToolProtocol
def generate_event_id() -> str:
@@ -33,85 +17,6 @@ def generate_event_id() -> str:
return str(uuid.uuid4())
def safe_json_parse(value: Any) -> dict[str, Any] | None:
"""Safely parse a value as JSON dict.
Args:
value: String or dict to parse
Returns:
Parsed dict or None if parsing fails
"""
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return None
def get_role_value(message: Any) -> str:
"""Extract role string from a message object.
Handles both enum roles (with .value) and string roles.
Args:
message: Message object with role attribute
Returns:
Role as lowercase string, or empty string if not found
"""
role = getattr(message, "role", None)
if role is None:
return ""
if hasattr(role, "value"):
return str(role.value)
return str(role)
def normalize_agui_role(raw_role: Any) -> str:
"""Normalize an AG-UI role to a standard role string.
Args:
raw_role: Raw role value from AG-UI message
Returns:
Normalized role string (user, assistant, system, or tool)
"""
if not isinstance(raw_role, str):
return "user"
role = raw_role.lower()
if role == "developer":
return "system"
if role in ALLOWED_AGUI_ROLES:
return role
return "user"
def extract_state_from_tool_args(
args: dict[str, Any] | None,
tool_arg_name: str,
) -> Any:
"""Extract state value from tool arguments based on config.
Args:
args: Parsed tool arguments dict
tool_arg_name: Name of the argument to extract, or "*" for entire args
Returns:
Extracted state value, or None if not found
"""
if not args:
return None
if tool_arg_name == "*":
return args
return args.get(tool_arg_name)
def merge_state(current: dict[str, Any], update: dict[str, Any]) -> dict[str, Any]:
"""Merge state updates.
@@ -75,10 +75,8 @@ def human_in_the_loop_agent(chat_client: ChatClientProtocol) -> ChatAgent:
9. "Calibrate systems"
10. "Final testing"
IMPORTANT: When you call generate_task_steps, the user will be shown the steps and asked to approve.
Do NOT output any text along with the function call - just call the function.
After the user approves and the function executes, THEN provide a brief acknowledgment like:
"The plan has been created with X steps selected."
After calling the function, provide a brief acknowledgment like:
"I've created a plan with 10 steps. You can customize which steps to enable before I proceed."
""",
chat_client=chat_client,
tools=[generate_task_steps],
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260107"
version = "1.0.0b251223"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -630,179 +630,3 @@ async def test_suppressed_summary_with_document_state():
# Should contain some reference to the document
full_text = "".join(e.delta for e in text_events)
assert "written" in full_text.lower() or "document" in full_text.lower()
async def test_function_approval_mode_executes_tool():
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@ai_function(
name="get_datetime",
description="Get the current date and time",
approval_mode="always_require",
)
def get_datetime() -> str:
return "2025/12/01 12:00:00"
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
agent = ChatAgent(
name="test_agent",
instructions="Test",
chat_client=StreamingChatClientStub(stream_fn),
tools=[get_datetime],
)
wrapper = AgentFrameworkAgent(agent=agent)
# Simulate the conversation history with:
# 1. User message asking for time
# 2. Assistant message with the function call that needs approval
# 3. Tool approval message from user
tool_result: dict[str, Any] = {"accepted": True}
input_data: dict[str, Any] = {
"messages": [
{
"role": "user",
"content": "What time is it?",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_get_datetime_123",
"type": "function",
"function": {
"name": "get_datetime",
"arguments": "{}",
},
}
],
},
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "call_get_datetime_123",
},
],
}
events: list[Any] = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Verify the run completed successfully
run_started = [e for e in events if e.type == "RUN_STARTED"]
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
assert len(run_started) == 1
assert len(run_finished) == 1
# Verify that a FunctionResultContent was created and sent to the agent
# Approved tool calls are resolved before the model run.
tool_result_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
tool_result_found = True
assert content.call_id == "call_get_datetime_123"
assert content.result == "2025/12/01 12:00:00"
break
assert tool_result_found, (
"FunctionResultContent should be included in messages sent to agent. "
"This is required for the model to see the approved tool execution result."
)
async def test_function_approval_mode_rejection():
"""Test that function approval rejection creates a rejection response."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@ai_function(
name="delete_all_data",
description="Delete all user data",
approval_mode="always_require",
)
def delete_all_data() -> str:
return "All data deleted"
async def stream_fn(
messages: MutableSequence[ChatMessage], chat_options: ChatOptions, **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Operation cancelled")])
agent = ChatAgent(
name="test_agent",
instructions="Test",
chat_client=StreamingChatClientStub(stream_fn),
tools=[delete_all_data],
)
wrapper = AgentFrameworkAgent(agent=agent)
# Simulate rejection
tool_result: dict[str, Any] = {"accepted": False}
input_data: dict[str, Any] = {
"messages": [
{
"role": "user",
"content": "Delete all my data",
},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_delete_123",
"type": "function",
"function": {
"name": "delete_all_data",
"arguments": "{}",
},
}
],
},
{
"role": "tool",
"content": json.dumps(tool_result),
"toolCallId": "call_delete_123",
},
],
}
events: list[Any] = []
async for event in wrapper.run_agent(input_data):
events.append(event)
# Verify the run completed
run_finished = [e for e in events if e.type == "RUN_FINISHED"]
assert len(run_finished) == 1
# Verify that a FunctionResultContent with rejection payload was created
rejection_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
rejection_found = True
assert content.call_id == "call_delete_123"
assert content.result == "Error: Tool call invocation was rejected by user."
break
assert rejection_found, (
"FunctionResultContent with rejection details should be included in messages sent to agent. "
"This tells the model that the tool was rejected."
)
@@ -52,8 +52,8 @@ async def test_tool_call_flow():
update2 = AgentRunResponseUpdate(contents=[tool_result])
events2 = await bridge.from_agent_run_update(update2)
# Should have: ToolCallEndEvent, ToolCallResultEvent
assert len(events2) == 2
# Should have: ToolCallEndEvent, ToolCallResultEvent, MessagesSnapshotEvent
assert len(events2) == 3
assert isinstance(events2[0], ToolCallEndEvent)
assert isinstance(events2[1], ToolCallResultEvent)
@@ -201,8 +201,7 @@ async def test_tool_result_with_none():
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# prepare_function_call_results serializes None as JSON "null"
assert events[1].content == "null"
assert events[1].content == ""
async def test_multiple_tool_results_in_sequence():
@@ -231,12 +230,7 @@ async def test_function_approval_request_basic():
"""Test FunctionApprovalRequestContent conversion."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
# Set require_confirmation=False to test just the function_approval_request event
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
require_confirmation=False,
)
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
func_call = FunctionCallContent(
call_id="call_123",
@@ -289,12 +283,14 @@ async def test_empty_predict_state_config():
assert "STATE_DELTA" not in event_types
assert "STATE_SNAPSHOT" not in event_types
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult
# Should have: ToolCallStart, ToolCallArgs, ToolCallEnd, ToolCallResult, MessagesSnapshot
# MessagesSnapshotEvent is emitted after tool results to track the conversation
assert event_types == [
"TOOL_CALL_START",
"TOOL_CALL_ARGS",
"TOOL_CALL_END",
"TOOL_CALL_RESULT",
"MESSAGES_SNAPSHOT",
]
@@ -692,97 +688,3 @@ async def test_state_delta_count_logging():
# State delta count should have incremented (one per unique state update)
assert bridge.state_delta_count >= 1
# Tests for list type tool results (MCP tool serialization)
async def test_tool_result_with_empty_list():
"""Test FunctionResultContent with empty list result."""
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# Empty list serializes as JSON empty array
assert events[1].content == "[]"
async def test_tool_result_with_single_text_content():
"""Test FunctionResultContent with single TextContent-like item (MCP tool result)."""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class MockTextContent:
text: str
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# TextContent text is extracted and serialized as JSON array
assert events[1].content == '["Hello from MCP tool!"]'
async def test_tool_result_with_multiple_text_contents():
"""Test FunctionResultContent with multiple TextContent-like items (MCP tool result)."""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class MockTextContent:
text: str
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[
FunctionResultContent(
call_id="call_123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[0].type == "TOOL_CALL_END"
assert events[1].type == "TOOL_CALL_RESULT"
# Multiple TextContent items should return JSON array
assert events[1].content == '["First result", "Second result"]'
async def test_tool_result_with_model_dump_objects():
"""Test FunctionResultContent with Pydantic BaseModel objects."""
from pydantic import BaseModel
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
class MockModel(BaseModel):
value: int
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentRunResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
)
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
assert events[1].type == "TOOL_CALL_RESULT"
# Should be properly serialized JSON array without double escaping
assert events[1].content == '[{"value": 1}, {"value": 2}]'
@@ -18,7 +18,6 @@ from agent_framework import (
from agent_framework._clients import BaseChatClient
from agent_framework._types import ChatResponse, ChatResponseUpdate
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
from agent_framework_ag_ui._orchestrators import ExecutionContext
StreamFn = Callable[..., AsyncIterator[ChatResponseUpdate]]
@@ -135,9 +134,5 @@ class StubAgent(AgentProtocol):
class TestExecutionContext(ExecutionContext):
"""ExecutionContext helper that allows setting messages for tests."""
def set_messages(self, messages: list[ChatMessage], *, normalize: bool = True) -> None:
if normalize:
self._messages = _deduplicate_messages(_sanitize_tool_history(messages))
else:
self._messages = messages
self._snapshot_messages = None
def set_messages(self, messages: list[ChatMessage]) -> None:
self._messages = messages
@@ -10,11 +10,9 @@ from agent_framework_ag_ui._events import AgentFrameworkEventBridge
async def test_function_approval_request_emission():
"""Test that CustomEvent is emitted for FunctionApprovalRequestContent."""
# Set require_confirmation=False to test just the function_approval_request event
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
require_confirmation=False,
)
# Create approval request
@@ -49,65 +47,11 @@ async def test_function_approval_request_emission():
assert event.value["function_call"]["arguments"]["subject"] == "Test"
async def test_function_approval_request_with_confirm_changes():
"""Test that confirm_changes is also emitted when require_confirmation=True."""
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
require_confirmation=True,
)
func_call = FunctionCallContent(
call_id="call_456",
name="delete_file",
arguments={"path": "/tmp/test.txt"},
)
approval_request = FunctionApprovalRequestContent(
id="approval_002",
function_call=func_call,
)
update = AgentRunResponseUpdate(contents=[approval_request])
events = await bridge.from_agent_run_update(update)
# Should emit: ToolCallEndEvent, CustomEvent, and confirm_changes (Start, Args, End) = 5 events
assert len(events) == 5
# Check ToolCallEndEvent
assert events[0].type == "TOOL_CALL_END"
assert events[0].tool_call_id == "call_456"
# Check function_approval_request CustomEvent
assert events[1].type == "CUSTOM"
assert events[1].name == "function_approval_request"
# Check confirm_changes tool call events
assert events[2].type == "TOOL_CALL_START"
assert events[2].tool_call_name == "confirm_changes"
assert events[3].type == "TOOL_CALL_ARGS"
# Verify confirm_changes includes function info for Dojo UI
import json
args = json.loads(events[3].delta)
assert args["function_name"] == "delete_file"
assert args["function_call_id"] == "call_456"
assert args["function_arguments"] == {"path": "/tmp/test.txt"}
assert args["steps"] == [
{
"description": "Execute delete_file",
"status": "enabled",
}
]
assert events[4].type == "TOOL_CALL_END"
async def test_multiple_approval_requests():
"""Test handling multiple approval requests in one update."""
# Set require_confirmation=False to simplify the test
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
require_confirmation=False,
)
func_call_1 = FunctionCallContent(
@@ -150,32 +94,3 @@ async def test_multiple_approval_requests():
assert events[3].type == "CUSTOM"
assert events[3].name == "function_approval_request"
assert events[3].value["id"] == "approval_2"
async def test_function_approval_request_sets_stop_flag():
"""Test that function approval request sets should_stop_after_confirm flag.
This ensures the orchestrator stops the run after emitting the approval request,
allowing the UI to send back an approval response.
"""
bridge = AgentFrameworkEventBridge(
run_id="test_run",
thread_id="test_thread",
)
assert bridge.should_stop_after_confirm is False
func_call = FunctionCallContent(
call_id="call_stop_test",
name="get_datetime",
arguments={},
)
approval_request = FunctionApprovalRequestContent(
id="approval_stop_test",
function_call=func_call,
)
update = AgentRunResponseUpdate(contents=[approval_request])
await bridge.from_agent_run_update(update)
assert bridge.should_stop_after_confirm is True
@@ -2,15 +2,12 @@
"""Tests for message adapters."""
import json
import pytest
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
from agent_framework import ChatMessage, FunctionCallContent, Role, TextContent
from agent_framework_ag_ui._message_adapters import (
agent_framework_messages_to_agui,
agui_messages_to_agent_framework,
agui_messages_to_snapshot_format,
extract_text_from_contents,
)
@@ -46,32 +43,6 @@ def test_agent_framework_to_agui_basic(sample_agent_framework_message):
assert messages[0]["id"] == "msg-123"
def test_agent_framework_to_agui_normalizes_dict_roles():
"""Dict inputs normalize unknown roles for UI compatibility."""
messages = [
{"role": "developer", "content": "policy"},
{"role": "weird_role", "content": "payload"},
]
converted = agent_framework_messages_to_agui(messages)
assert converted[0]["role"] == "system"
assert converted[1]["role"] == "user"
def test_agui_snapshot_format_normalizes_roles():
"""Snapshot normalization coerces roles into supported AG-UI values."""
messages = [
{"role": "Developer", "content": "policy"},
{"role": "unknown", "content": "payload"},
]
normalized = agui_messages_to_snapshot_format(messages)
assert normalized[0]["role"] == "system"
assert normalized[1]["role"] == "user"
def test_agui_tool_result_to_agent_framework():
"""Test converting AG-UI tool result message to Agent Framework."""
tool_result_message = {
@@ -97,237 +68,6 @@ def test_agui_tool_result_to_agent_framework():
assert message.additional_properties.get("tool_call_id") == "call_123"
def test_agui_tool_approval_updates_tool_call_arguments():
"""Tool approval updates matching tool call arguments for snapshots and agent context."""
messages_input = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "generate_task_steps",
"arguments": {
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Brew coffee", "status": "enabled"},
{"description": "Serve coffee", "status": "enabled"},
]
},
},
}
],
"id": "msg_1",
},
{
"role": "tool",
"content": json.dumps(
{
"accepted": True,
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Serve coffee", "status": "enabled"},
],
}
),
"toolCallId": "call_123",
"id": "msg_2",
},
]
messages = agui_messages_to_agent_framework(messages_input)
assert len(messages) == 2
assistant_msg = messages[0]
func_call = next(content for content in assistant_msg.contents if isinstance(content, FunctionCallContent))
assert func_call.arguments == {
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Brew coffee", "status": "disabled"},
{"description": "Serve coffee", "status": "enabled"},
]
}
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Brew coffee", "status": "disabled"},
{"description": "Serve coffee", "status": "enabled"},
]
}
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
)
assert approval_content.function_call.parse_arguments() == {
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Serve coffee", "status": "enabled"},
]
}
assert approval_content.additional_properties is not None
assert approval_content.additional_properties.get("ag_ui_state_args") == {
"steps": [
{"description": "Boil water", "status": "enabled"},
{"description": "Brew coffee", "status": "disabled"},
{"description": "Serve coffee", "status": "enabled"},
]
}
def test_agui_tool_approval_from_confirm_changes_maps_to_function_call():
"""Confirm_changes approvals map back to the original tool call when metadata is present."""
messages_input = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_tool",
"type": "function",
"function": {"name": "get_datetime", "arguments": {}},
},
{
"id": "call_confirm",
"type": "function",
"function": {
"name": "confirm_changes",
"arguments": {"function_call_id": "call_tool"},
},
},
],
"id": "msg_1",
},
{
"role": "tool",
"content": json.dumps({"accepted": True, "function_call_id": "call_tool"}),
"toolCallId": "call_confirm",
"id": "msg_2",
},
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
)
assert approval_content.function_call.call_id == "call_tool"
assert approval_content.function_call.name == "get_datetime"
assert approval_content.function_call.parse_arguments() == {}
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {}
def test_agui_tool_approval_from_confirm_changes_falls_back_to_sibling_call():
"""Confirm_changes approvals map to the only sibling tool call when metadata is missing."""
messages_input = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_tool",
"type": "function",
"function": {"name": "get_datetime", "arguments": {}},
},
{
"id": "call_confirm",
"type": "function",
"function": {"name": "confirm_changes", "arguments": {}},
},
],
"id": "msg_1",
},
{
"role": "tool",
"content": json.dumps(
{
"accepted": True,
"steps": [{"description": "Approve get_datetime", "status": "enabled"}],
}
),
"toolCallId": "call_confirm",
"id": "msg_2",
},
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
)
assert approval_content.function_call.call_id == "call_tool"
assert approval_content.function_call.name == "get_datetime"
assert approval_content.function_call.parse_arguments() == {}
assert messages_input[0]["tool_calls"][0]["function"]["arguments"] == {}
def test_agui_tool_approval_from_generate_task_steps_maps_to_function_call():
"""Approval tool payloads map to the referenced function call when function_call_id is present."""
messages_input = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call_tool",
"type": "function",
"function": {"name": "get_datetime", "arguments": {}},
},
{
"id": "call_steps",
"type": "function",
"function": {
"name": "generate_task_steps",
"arguments": {
"function_name": "get_datetime",
"function_call_id": "call_tool",
"function_arguments": {},
"steps": [{"description": "Execute get_datetime", "status": "enabled"}],
},
},
},
],
"id": "msg_1",
},
{
"role": "tool",
"content": json.dumps(
{
"accepted": True,
"steps": [{"description": "Execute get_datetime", "status": "enabled"}],
}
),
"toolCallId": "call_steps",
"id": "msg_2",
},
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
)
assert approval_content.function_call.call_id == "call_tool"
assert approval_content.function_call.name == "get_datetime"
assert approval_content.function_call.parse_arguments() == {}
def test_agui_multiple_messages_to_agent_framework():
"""Test converting multiple AG-UI messages."""
messages_input = [
@@ -538,119 +278,3 @@ def test_extract_text_from_custom_contents():
result = extract_text_from_contents(contents)
assert result == "Custom Mixed"
# Tests for FunctionResultContent serialization in agent_framework_messages_to_agui
def test_agent_framework_to_agui_function_result_dict():
"""Test converting FunctionResultContent with dict result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
assert agui_msg["role"] == "tool"
assert agui_msg["toolCallId"] == "call-123"
assert agui_msg["content"] == '{"key": "value", "count": 42}'
def test_agent_framework_to_agui_function_result_none():
"""Test converting FunctionResultContent with None result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=None)],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# None serializes as JSON null
assert agui_msg["content"] == "null"
def test_agent_framework_to_agui_function_result_string():
"""Test converting FunctionResultContent with string result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
assert agui_msg["content"] == "plain text result"
def test_agent_framework_to_agui_function_result_empty_list():
"""Test converting FunctionResultContent with empty list result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[])],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# Empty list serializes as JSON empty array
assert agui_msg["content"] == "[]"
def test_agent_framework_to_agui_function_result_single_text_content():
"""Test converting FunctionResultContent with single TextContent-like item."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# TextContent text is extracted and serialized as JSON array
assert agui_msg["content"] == '["Hello from MCP!"]'
def test_agent_framework_to_agui_function_result_multiple_text_contents():
"""Test converting FunctionResultContent with multiple TextContent-like items."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
call_id="call-123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
],
message_id="msg-789",
)
messages = agent_framework_messages_to_agui([msg])
assert len(messages) == 1
agui_msg = messages[0]
# Multiple items should return JSON array
assert agui_msg["content"] == '["First result", "Second result"]'
@@ -2,7 +2,10 @@
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
from agent_framework_ag_ui._orchestration._message_hygiene import (
deduplicate_messages,
sanitize_tool_history,
)
def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
@@ -23,7 +26,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
),
]
sanitized = _sanitize_tool_history(messages)
sanitized = sanitize_tool_history(messages)
tool_messages = [
msg for msg in sanitized if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "tool"
@@ -45,6 +48,6 @@ def test_deduplicate_messages_prefers_non_empty_tool_results() -> None:
),
]
deduped = _deduplicate_messages(messages)
deduped = deduplicate_messages(messages)
assert len(deduped) == 1
assert deduped[0].contents[0].result == "result data"
@@ -42,29 +42,6 @@ class DummyAgent:
yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant")
class RecordingAgent:
"""Agent stub that captures messages passed to run_stream."""
def __init__(self) -> None:
self.chat_options = SimpleNamespace(tools=[], response_format=None)
self.tools: list[Any] = []
self.chat_client = SimpleNamespace(
function_invocation_configuration=FunctionInvocationConfiguration(),
)
self.seen_messages: list[Any] | None = None
async def run_stream(
self,
messages: list[Any],
*,
thread: Any,
tools: list[Any] | None = None,
**kwargs: Any,
) -> AsyncGenerator[AgentRunResponseUpdate, None]:
self.seen_messages = messages
yield AgentRunResponseUpdate(contents=[TextContent(text="ok")], role="assistant")
async def test_default_orchestrator_merges_client_tools() -> None:
"""Client tool declarations are merged with server tools before running agent."""
@@ -174,104 +151,3 @@ async def test_default_orchestrator_with_snake_case_ids() -> None:
last_event = events[-1]
assert last_event.run_id == "test-snakecase-runid"
assert last_event.thread_id == "test-snakecase-threadid"
async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
"""State context should be injected when current state differs from tool call args."""
agent = RecordingAgent()
orchestrator = DefaultOrchestrator()
tool_recipe = {"title": "Salad", "special_preferences": []}
current_recipe = {"title": "Salad", "special_preferences": ["Vegetarian"]}
input_data = {
"state": {"recipe": current_recipe},
"messages": [
{"role": "system", "content": "Instructions"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "update_recipe", "arguments": {"recipe": tool_recipe}},
}
],
},
{"role": "user", "content": "What are the dietary preferences?"},
],
}
context = ExecutionContext(
input_data=input_data,
agent=agent,
config=AgentConfig(
state_schema={"recipe": {"type": "object"}},
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
require_confirmation=False,
),
)
async for _event in orchestrator.run(context):
pass
assert agent.seen_messages is not None
state_messages = []
for msg in agent.seen_messages:
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert state_messages
assert "Vegetarian" in state_messages[0]
async def test_state_context_not_injected_when_tool_call_matches_state() -> None:
"""State context should be skipped when tool call args match current state."""
agent = RecordingAgent()
orchestrator = DefaultOrchestrator()
input_data = {
"messages": [
{"role": "system", "content": "Instructions"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "update_recipe", "arguments": {"recipe": {}}},
}
],
},
{"role": "user", "content": "What are the dietary preferences?"},
],
}
context = ExecutionContext(
input_data=input_data,
agent=agent,
config=AgentConfig(
state_schema={"recipe": {"type": "object"}},
predict_state_config={"recipe": {"tool": "update_recipe", "tool_argument": "recipe"}},
require_confirmation=False,
),
)
async for _event in orchestrator.run(context):
pass
assert agent.seen_messages is not None
state_messages = []
for msg in agent.seen_messages:
role_value = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert not state_messages
@@ -62,7 +62,7 @@ async def test_human_in_the_loop_json_decode_error() -> None:
agent=agent,
config=AgentConfig(),
)
context.set_messages(messages, normalize=False)
context.set_messages(messages)
assert orchestrator.can_handle(context)
@@ -385,8 +385,8 @@ async def test_state_context_injection() -> None:
assert "banana" in system_messages[0].contents[0].text
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
"""Test state context is injected when state is provided, even with tool calls."""
async def test_no_state_context_injection_with_tool_calls() -> None:
"""Test state context is NOT injected if conversation has tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
messages = [
@@ -420,13 +420,13 @@ async def test_state_context_injection_with_tool_calls_and_input_state() -> None
async for event in orchestrator.run(context):
events.append(event)
# Should inject state context system message because input state is provided
# Should NOT inject state context system message since conversation has tool calls
system_messages = [
msg
for msg in agent.messages_received
if (msg.role.value if hasattr(msg.role, "value") else str(msg.role)) == "system"
]
assert len(system_messages) == 1
assert len(system_messages) == 0
async def test_structured_output_processing() -> None:
@@ -685,54 +685,6 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
assert len(user_messages) == 1
async def test_confirm_changes_closes_active_message_before_finish() -> None:
"""Confirm-changes flow closes any active text message before run finishes."""
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
from agent_framework import FunctionCallContent, FunctionResultContent
updates = [
AgentRunResponseUpdate(
contents=[
FunctionCallContent(
name="write_document_local",
call_id="call_1",
arguments='{"document": "Draft"}',
)
]
),
AgentRunResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]),
]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [{"role": "user", "content": "Start"}]}
agent = StubAgent(
chat_options=DEFAULT_CHAT_OPTIONS,
updates=updates,
)
context = TestExecutionContext(
input_data=input_data,
agent=agent,
config=AgentConfig(
predict_state_config={"document": {"tool": "write_document_local", "tool_argument": "document"}},
require_confirmation=True,
),
)
events: list[Any] = []
async for event in orchestrator.run(context):
events.append(event)
start_events = [e for e in events if isinstance(e, TextMessageStartEvent)]
end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
assert len(start_events) == 1
assert len(end_events) == 1
assert end_events[0].message_id == start_events[0].message_id
end_index = events.index(end_events[0])
finished_index = events.index([e for e in events if e.type == "RUN_FINISHED"][0])
assert end_index < finished_index
async def test_tool_result_kept_when_call_id_matches() -> None:
"""Test tool result is kept when call_id matches pending tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
+1 -5
View File
@@ -5,11 +5,7 @@
from dataclasses import dataclass
from datetime import date, datetime
from agent_framework_ag_ui._utils import (
generate_event_id,
make_json_safe,
merge_state,
)
from agent_framework_ag_ui._utils import generate_event_id, make_json_safe, merge_state
def test_generate_event_id():
@@ -1,4 +1,5 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import AsyncIterable, MutableMapping, MutableSequence, Sequence
from typing import Any, ClassVar, Final, TypeVar
@@ -12,10 +13,7 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
ErrorContent,
FinishReason,
FunctionCallContent,
FunctionResultContent,
@@ -23,8 +21,6 @@ from agent_framework import (
HostedFileContent,
HostedMCPTool,
HostedWebSearchTool,
MCPServerToolCallContent,
MCPServerToolResultContent,
Role,
TextContent,
TextReasoningContent,
@@ -49,8 +45,6 @@ from anthropic.types.beta import (
BetaTextBlock,
BetaUsage,
)
from anthropic.types.beta.beta_bash_code_execution_tool_result_error import BetaBashCodeExecutionToolResultError
from anthropic.types.beta.beta_code_execution_tool_result_error import BetaCodeExecutionToolResultError
from pydantic import SecretStr, ValidationError
logger = get_logger("agent_framework.anthropic")
@@ -511,7 +505,7 @@ class AnthropicClient(BaseChatClient):
usage_details=self._parse_usage_from_anthropic(message.usage),
model_id=message.model,
finish_reason=FINISH_REASON_MAP.get(message.stop_reason) if message.stop_reason else None,
raw_representation=message,
raw_response=message,
)
def _process_stream_event(self, event: BetaRawMessageStreamEvent) -> ChatResponseUpdate | None:
@@ -536,14 +530,13 @@ class AnthropicClient(BaseChatClient):
finish_reason=FINISH_REASON_MAP.get(event.message.stop_reason)
if event.message.stop_reason
else None,
raw_representation=event,
raw_response=event,
)
case "message_delta":
usage = self._parse_usage_from_anthropic(event.usage)
return ChatResponseUpdate(
contents=[UsageContent(details=usage, raw_representation=event.usage)] if usage else [],
finish_reason=FINISH_REASON_MAP.get(event.delta.stop_reason) if event.delta.stop_reason else None,
raw_representation=event,
raw_response=event,
)
case "message_stop":
logger.debug("Received message_stop event; no content to process.")
@@ -551,13 +544,13 @@ class AnthropicClient(BaseChatClient):
contents = self._parse_contents_from_anthropic([event.content_block])
return ChatResponseUpdate(
contents=contents,
raw_representation=event,
raw_response=event,
)
case "content_block_delta":
contents = self._parse_contents_from_anthropic([event.delta])
return ChatResponseUpdate(
contents=contents,
raw_representation=event,
raw_response=event,
)
case "content_block_stop":
logger.debug("Received content_block_stop event; no content to process.")
@@ -595,49 +588,23 @@ class AnthropicClient(BaseChatClient):
)
case "tool_use" | "mcp_tool_use" | "server_tool_use":
self._last_call_id_name = (content_block.id, content_block.name)
if content_block.type == "mcp_tool_use":
contents.append(
MCPServerToolCallContent(
call_id=content_block.id,
tool_name=content_block.name,
server_name=None,
arguments=content_block.input,
raw_representation=content_block,
)
)
elif "code_execution" in (content_block.name or ""):
contents.append(
CodeInterpreterToolCallContent(
call_id=content_block.id,
inputs=[TextContent(text=str(content_block.input), raw_representation=content_block)],
raw_representation=content_block,
)
)
else:
contents.append(
FunctionCallContent(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
raw_representation=content_block,
)
contents.append(
FunctionCallContent(
call_id=content_block.id,
name=content_block.name,
arguments=content_block.input,
raw_representation=content_block,
)
)
case "mcp_tool_result":
call_id, name = self._last_call_id_name or (None, None)
parsed_output: list[Contents] | None = None
if content_block.content:
if isinstance(content_block.content, list):
parsed_output = self._parse_contents_from_anthropic(content_block.content)
elif isinstance(content_block.content, (str, bytes)):
parsed_output = [
TextContent(text=str(content_block.content), raw_representation=content_block)
]
else:
parsed_output = self._parse_contents_from_anthropic([content_block.content])
contents.append(
MCPServerToolResultContent(
FunctionResultContent(
call_id=content_block.tool_use_id,
output=parsed_output,
name=name if name and call_id == content_block.tool_use_id else "mcp_tool",
result=self._parse_contents_from_anthropic(content_block.content)
if isinstance(content_block.content, list)
else content_block.content,
raw_representation=content_block,
)
)
@@ -651,183 +618,30 @@ class AnthropicClient(BaseChatClient):
raw_representation=content_block,
)
)
case "code_execution_tool_result":
code_outputs: list[Contents] = []
if content_block.content:
if isinstance(content_block.content, BetaCodeExecutionToolResultError):
code_outputs.append(
ErrorContent(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
)
else:
if content_block.content.stdout:
code_outputs.append(
TextContent(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
code_outputs.append(
ErrorContent(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for code_file_content in content_block.content.content:
code_outputs.append(
HostedFileContent(
file_id=code_file_content.file_id, raw_representation=code_file_content
)
)
contents.append(
CodeInterpreterToolResultContent(
call_id=content_block.tool_use_id,
raw_representation=content_block,
outputs=code_outputs,
case (
"code_execution_tool_result"
| "bash_code_execution_tool_result"
| "text_editor_code_execution_tool_result"
):
call_id, name = self._last_call_id_name or (None, None)
if (
content_block.content
and (
content_block.content.type == "bash_code_execution_result"
or content_block.content.type == "code_execution_result"
)
)
case "bash_code_execution_tool_result":
bash_outputs: list[Contents] = []
if content_block.content:
if isinstance(
content_block.content,
BetaBashCodeExecutionToolResultError,
):
bash_outputs.append(
ErrorContent(
message=content_block.content.error_code,
raw_representation=content_block.content,
)
)
else:
if content_block.content.stdout:
bash_outputs.append(
TextContent(
text=content_block.content.stdout,
raw_representation=content_block.content,
)
)
if content_block.content.stderr:
bash_outputs.append(
ErrorContent(
message=content_block.content.stderr,
raw_representation=content_block.content,
)
)
for bash_file_content in content_block.content.content:
and content_block.content.content
):
for result_content in content_block.content.content:
if hasattr(result_content, "file_id"):
contents.append(
HostedFileContent(
file_id=bash_file_content.file_id, raw_representation=bash_file_content
)
HostedFileContent(file_id=result_content.file_id, raw_representation=result_content)
)
contents.append(
FunctionResultContent(
call_id=content_block.tool_use_id,
name=content_block.type,
result=bash_outputs,
raw_representation=content_block,
)
)
case "text_editor_code_execution_tool_result":
text_editor_outputs: list[Contents] = []
match content_block.content.type:
case "text_editor_code_execution_tool_result_error":
text_editor_outputs.append(
ErrorContent(
message=content_block.content.error_code
and getattr(content_block.content, "error_message", ""),
raw_representation=content_block.content,
)
)
case "text_editor_code_execution_view_result":
annotations = (
[
CitationAnnotation(
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
start_index=content_block.content.start_line,
end_index=content_block.content.start_line
+ (content_block.content.num_lines or 0),
)
],
)
]
if content_block.content.num_lines is not None
and content_block.content.start_line is not None
else None
)
text_editor_outputs.append(
TextContent(
text=content_block.content.content,
annotations=annotations,
raw_representation=content_block.content,
)
)
case "text_editor_code_execution_str_replace_result":
old_annotation = (
CitationAnnotation(
raw_representation=content_block.content,
annotated_regions=[
TextSpanRegion(
start_index=content_block.content.old_start or 0,
end_index=(
(content_block.content.old_start or 0)
+ (content_block.content.old_lines or 0)
),
)
],
)
if content_block.content.old_lines is not None
and content_block.content.old_start is not None
else None
)
new_annotation = (
CitationAnnotation(
raw_representation=content_block.content,
snippet="\n".join(content_block.content.lines)
if content_block.content.lines
else None,
annotated_regions=[
TextSpanRegion(
start_index=content_block.content.new_start or 0,
end_index=(
(content_block.content.new_start or 0)
+ (content_block.content.new_lines or 0)
),
)
],
)
if content_block.content.new_lines is not None
and content_block.content.new_start is not None
else None
)
annotations = [ann for ann in [old_annotation, new_annotation] if ann is not None]
text_editor_outputs.append(
TextContent(
text=(
"\n".join(content_block.content.lines) if content_block.content.lines else ""
),
annotations=annotations or None,
raw_representation=content_block.content,
)
)
case "text_editor_code_execution_create_result":
text_editor_outputs.append(
TextContent(
text=f"File update: {content_block.content.is_file_update}",
raw_representation=content_block.content,
)
)
contents.append(
FunctionResultContent(
call_id=content_block.tool_use_id,
name=content_block.type,
result=text_editor_outputs,
name=name if name and call_id == content_block.tool_use_id else "code_execution_tool",
result=content_block.content,
raw_representation=content_block,
)
)
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -300,26 +300,13 @@ class AzureAIClient(OpenAIBaseResponsesClient):
raise ServiceInvalidRequestError("response_format must be a Pydantic model or mapping.")
async def _get_agent_reference_or_create(
self,
run_options: dict[str, Any],
messages_instructions: str | None,
chat_options: ChatOptions | None = None,
self, run_options: dict[str, Any], messages_instructions: str | None
) -> dict[str, str]:
"""Determine which agent to use and create if needed.
Args:
run_options: The prepared options for the API call.
messages_instructions: Instructions extracted from messages.
chat_options: The chat options containing response_format and other settings.
Returns:
dict[str, str]: The agent reference to use.
"""
# chat_options is needed separately because the base class excludes response_format
# from run_options (transforming it to text/text_format for OpenAI). Azure's agent
# creation API requires the original response_format to build its own config format.
if chat_options is None:
chat_options = ChatOptions()
# Agent name must be explicitly provided by the user.
if self.agent_name is None:
raise ServiceInitializationError(
@@ -354,14 +341,8 @@ class AzureAIClient(OpenAIBaseResponsesClient):
if "top_p" in run_options:
args["top_p"] = run_options["top_p"]
# response_format is accessed from chat_options or additional_properties
# since the base class excludes it from run_options
response_format: Any = (
chat_options.response_format
if chat_options.response_format is not None
else chat_options.additional_properties.get("response_format")
)
if response_format:
if "response_format" in run_options:
response_format = run_options["response_format"]
args["text"] = PromptAgentDefinitionText(format=self._create_text_format_config(response_format))
# Combine instructions from messages and options
@@ -409,12 +390,12 @@ class AzureAIClient(OpenAIBaseResponsesClient):
if not self._is_application_endpoint:
# Application-scoped response APIs do not support "agent" property.
agent_reference = await self._get_agent_reference_or_create(run_options, instructions, chat_options)
agent_reference = await self._get_agent_reference_or_create(run_options, instructions)
run_options["extra_body"] = {"agent": agent_reference}
# Remove properties that are not supported on request level
# but were configured on agent level
exclude = ["model", "tools", "response_format", "temperature", "top_p", "text", "text_format"]
exclude = ["model", "tools", "response_format", "temperature", "top_p"]
for property in exclude:
run_options.pop(property, None)
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -723,10 +723,9 @@ async def test_azure_ai_client_agent_creation_with_response_format(
mock_agent.version = "1.0"
mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent)
run_options = {"model": "test-model"}
chat_options = ChatOptions(response_format=ResponseFormatModel)
run_options = {"model": "test-model", "response_format": ResponseFormatModel}
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
await client._get_agent_reference_or_create(run_options, None) # type: ignore
# Verify agent was created with response format configuration
call_args = mock_project_client.agents.create_version.call_args
@@ -777,18 +776,19 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
"additionalProperties": False,
}
run_options = {"model": "test-model"}
response_format_mapping = {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
run_options = {
"model": "test-model",
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
}
chat_options = ChatOptions(response_format=response_format_mapping) # type: ignore
await client._get_agent_reference_or_create(run_options, None, chat_options) # type: ignore
await client._get_agent_reference_or_create(run_options, None) # type: ignore
call_args = mock_project_client.agents.create_version.call_args
created_definition = call_args[1]["definition"]
@@ -805,7 +805,7 @@ async def test_azure_ai_client_agent_creation_with_mapping_response_format(
async def test_azure_ai_client_prepare_options_excludes_response_format(
mock_project_client: MagicMock,
) -> None:
"""Test that prepare_options excludes response_format, text, and text_format from final run options."""
"""Test that prepare_options excludes response_format from final run options."""
client = create_test_azure_ai_client(mock_project_client, agent_name="test-agent", agent_version="1.0")
messages = [ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])]
@@ -815,12 +815,7 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
patch.object(
client.__class__.__bases__[0],
"_prepare_options",
return_value={
"model": "test-model",
"response_format": ResponseFormatModel,
"text": {"format": {"type": "json_schema", "name": "test"}},
"text_format": ResponseFormatModel,
},
return_value={"model": "test-model", "response_format": ResponseFormatModel},
),
patch.object(
client,
@@ -830,11 +825,8 @@ async def test_azure_ai_client_prepare_options_excludes_response_format(
):
run_options = await client._prepare_options(messages, chat_options)
# response_format, text, and text_format should be excluded from final run options
# because they are configured at agent level, not request level
# response_format should be excluded from final run options
assert "response_format" not in run_options
assert "text" not in run_options
assert "text_format" not in run_options
# But extra_body should contain agent reference
assert "extra_body" in run_options
assert run_options["extra_body"]["agent"]["name"] == "test-agent"
@@ -1017,91 +1009,3 @@ async def test_azure_ai_chat_client_agent_with_tools() -> None:
assert response.text is not None
assert len(response.text) > 0
assert any(word in response.text.lower() for word in ["sunny", "25"])
class ReleaseBrief(BaseModel):
"""Structured output model for release brief."""
title: str = Field(description="A short title for the release.")
summary: str = Field(description="A brief summary of what was released.")
highlights: list[str] = Field(description="Key highlights from the release.")
model_config = ConfigDict(extra="forbid")
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_response_format() -> None:
"""Test ChatAgent with response_format (structured output) using AzureAIClient."""
async with (
temporary_chat_client(agent_name="ResponseFormatAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Summarize the following release notes into a ReleaseBrief:\n\n"
"Version 2.0 Release Notes:\n"
"- Added new streaming API for real-time responses\n"
"- Improved error handling with detailed messages\n"
"- Performance boost of 50% in batch processing\n"
"- Fixed memory leak in connection pooling",
response_format=ReleaseBrief,
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.value is not None
assert isinstance(response.value, ReleaseBrief)
# Validate structured output fields
brief = response.value
assert len(brief.title) > 0
assert len(brief.summary) > 0
assert len(brief.highlights) > 0
@pytest.mark.flaky
@skip_if_azure_ai_integration_tests_disabled
async def test_azure_ai_chat_client_agent_with_runtime_json_schema() -> None:
"""Test ChatAgent with runtime JSON schema (structured output) using AzureAIClient."""
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
async with (
temporary_chat_client(agent_name="RuntimeSchemaAgent") as chat_client,
ChatAgent(chat_client=chat_client) as agent,
):
response = await agent.run(
"Give a brief weather digest for Seattle.",
additional_chat_options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Parse JSON and validate structure
import json
parsed = json.loads(response.text)
assert "location" in parsed
assert "conditions" in parsed
assert "temperature_c" in parsed
assert "advisory" in parsed
@@ -2,9 +2,9 @@
import importlib.metadata
from agent_framework_durabletask import AgentCallbackContext, AgentResponseCallbackProtocol, DurableAIAgent
from ._app import AgentFunctionApp
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._orchestration import DurableAIAgent
try:
__version__ = importlib.metadata.version(__name__)
@@ -8,16 +8,16 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
import json
import re
import uuid
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework import AgentProtocol, get_logger
from agent_framework_durabletask import (
from ._callbacks import AgentResponseCallbackProtocol
from ._constants import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
MIMETYPE_APPLICATION_JSON,
@@ -28,17 +28,12 @@ from agent_framework_durabletask import (
THREAD_ID_HEADER,
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
AgentResponseCallbackProtocol,
AgentSessionId,
ApiResponseFields,
DurableAgentState,
DurableAIAgent,
RunRequest,
)
from ._durable_agent_state import DurableAgentState
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
from ._models import AgentSessionId, RunRequest
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
logger = get_logger("agent_framework.azurefunctions")
@@ -299,7 +294,7 @@ class AgentFunctionApp(DFAppBase):
self,
context: AgentOrchestrationContextType,
agent_name: str,
) -> DurableAIAgent[AgentTask]:
) -> DurableAIAgent:
"""Return a DurableAIAgent proxy for a registered agent.
Args:
@@ -310,15 +305,14 @@ class AgentFunctionApp(DFAppBase):
ValueError: If the requested agent has not been registered.
Returns:
DurableAIAgent[AgentTask] wrapper bound to the orchestration context.
DurableAIAgent wrapper bound to the orchestration context.
"""
normalized_name = str(agent_name)
if normalized_name not in self._agent_metadata:
raise ValueError(f"Agent '{normalized_name}' is not registered with this app.")
executor = AzureFunctionsAgentExecutor(context)
return DurableAIAgent(executor, normalized_name)
return DurableAIAgent(context, normalized_name)
def _setup_agent_functions(
self,
@@ -381,6 +375,8 @@ class AgentFunctionApp(DFAppBase):
"enable_tool_calls": true|false (optional, default: true)
}
"""
logger.debug(f"[HTTP Trigger] Received request on route: /api/agents/{agent_name}/run")
request_response_format: str = REQUEST_RESPONSE_FORMAT_JSON
thread_id: str | None = None
@@ -389,9 +385,9 @@ class AgentFunctionApp(DFAppBase):
thread_id = self._resolve_thread_id(req=req, req_body=req_body)
wait_for_response = self._should_wait_for_response(req=req, req_body=req_body)
logger.debug(
f"[HTTP Trigger] Message: {message}, Thread ID: {thread_id}, wait_for_response: {wait_for_response}"
)
logger.debug(f"[HTTP Trigger] Message: {message}")
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
if not message:
logger.warning("[HTTP Trigger] Request rejected: Missing message")
@@ -405,18 +401,15 @@ class AgentFunctionApp(DFAppBase):
session_id = self._create_session_id(agent_name, thread_id)
correlation_id = self._generate_unique_id()
logger.debug(
f"[HTTP Trigger] Calling entity to run agent using session ID: {session_id} "
f"and correlation ID: {correlation_id}"
)
logger.debug(f"[HTTP Trigger] Using session ID: {session_id}")
logger.debug(f"[HTTP Trigger] Generated correlation ID: {correlation_id}")
logger.debug("[HTTP Trigger] Calling entity to run agent...")
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
entity_instance_id = session_id.to_entity_id()
run_request = self._build_request_data(
req_body,
message,
thread_id,
correlation_id,
request_response_format,
)
@@ -629,16 +622,14 @@ class AgentFunctionApp(DFAppBase):
session_id = AgentSessionId.with_random_key(agent_name)
# Build entity instance ID
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
entity_instance_id = session_id.to_entity_id()
# Create run request
correlation_id = self._generate_unique_id()
run_request = self._build_request_data(
req_body={"message": query, "role": "user"},
message=query,
thread_id=str(session_id),
correlation_id=correlation_id,
request_response_format=REQUEST_RESPONSE_FORMAT_TEXT,
)
@@ -790,7 +781,7 @@ class AgentFunctionApp(DFAppBase):
agent_response = state.try_get_agent_response(correlation_id)
if agent_response:
result = self._build_success_result(
response_message=agent_response.text,
response_data=agent_response,
message=message,
thread_id=thread_id,
correlation_id=correlation_id,
@@ -836,22 +827,23 @@ class AgentFunctionApp(DFAppBase):
)
def _build_success_result(
self, response_message: str, message: str, thread_id: str, correlation_id: str, state: DurableAgentState
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
) -> dict[str, Any]:
"""Build the success result returned to the HTTP caller."""
return self._build_response_payload(
response=response_message,
response=response_data.get("content"),
message=message,
thread_id=thread_id,
status="success",
correlation_id=correlation_id,
extra_fields={ApiResponseFields.MESSAGE_COUNT: state.message_count},
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
)
def _build_request_data(
self,
req_body: dict[str, Any],
message: str,
thread_id: str,
correlation_id: str,
request_response_format: str,
) -> dict[str, Any]:
@@ -865,8 +857,8 @@ class AgentFunctionApp(DFAppBase):
request_response_format=request_response_format,
response_format=req_body.get("response_format"),
enable_tool_calls=enable_tool_calls,
thread_id=thread_id,
correlation_id=correlation_id,
created_at=datetime.now(timezone.utc),
).to_dict()
def _build_accepted_response(self, message: str, thread_id: str, correlation_id: str) -> dict[str, Any]:
@@ -918,13 +910,15 @@ class AgentFunctionApp(DFAppBase):
def _generate_unique_id(self) -> str:
"""Generate a new unique identifier."""
import uuid
return uuid.uuid4().hex
def _create_session_id(self, agent_name: str, thread_id: str | None) -> AgentSessionId:
def _create_session_id(self, func_name: str, thread_id: str | None) -> AgentSessionId:
"""Create a session identifier using the provided thread id or a random value."""
if thread_id:
return AgentSessionId(name=agent_name, key=thread_id)
return AgentSessionId.with_random_key(name=agent_name)
return AgentSessionId(name=func_name, key=thread_id)
return AgentSessionId.with_random_key(name=func_name)
def _resolve_thread_id(self, req: func.HttpRequest, req_body: dict[str, Any]) -> str:
"""Retrieve the thread identifier from request body or query parameters."""
@@ -28,10 +28,6 @@ WAIT_FOR_RESPONSE_HEADER: str = "x-ms-wait-for-response"
DEFAULT_MAX_POLL_RETRIES: int = 30
DEFAULT_POLL_INTERVAL_SECONDS: float = 1.0
# TTL configuration
DEFAULT_TIME_TO_LIVE_DAYS: int = 14
MINIMUM_TTL_SIGNAL_DELAY_MINUTES: int = 5
# =============================================================================
# JSON Field Name Constants for Durable Agent State Serialization
@@ -102,9 +98,6 @@ class DurableStateFields:
# History field
CONVERSATION_HISTORY: Final[str] = "conversationHistory"
# TTL field
EXPIRATION_TIME_UTC: Final[str] = "expirationTimeUtc"
class ContentTypes:
"""Content type discriminator values for the $type field.
@@ -53,10 +53,10 @@ from agent_framework import (
)
from dateutil import parser as date_parser
from ._constants import ContentTypes, DurableStateFields
from ._constants import ApiResponseFields, ContentTypes, DurableStateFields
from ._models import RunRequest, serialize_response_format
logger = get_logger("agent_framework.durabletask.durable_agent_state")
logger = get_logger("agent_framework.azurefunctions.durable_agent_state")
class DurableAgentStateEntryJsonType(str, Enum):
@@ -82,10 +82,7 @@ def _parse_created_at(value: Any) -> datetime:
except (ValueError, TypeError):
pass
logger.warning(
f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}",
stack_info=True,
)
logger.warning("Invalid or missing created_at value in durable agent state; defaulting to current UTC time.")
return datetime.now(tz=timezone.utc)
@@ -332,29 +329,24 @@ class DurableAgentStateData:
Attributes:
conversation_history: Ordered list of conversation entries (requests and responses)
extension_data: Optional dictionary for custom metadata (not part of core schema)
expiration_time_utc: Optional UTC datetime when the entity should expire (TTL feature)
"""
conversation_history: list[DurableAgentStateEntry]
extension_data: dict[str, Any] | None
expiration_time_utc: datetime | None
def __init__(
self,
conversation_history: list[DurableAgentStateEntry] | None = None,
extension_data: dict[str, Any] | None = None,
expiration_time_utc: datetime | None = None,
) -> None:
"""Initialize the data container.
Args:
conversation_history: Initial conversation history (defaults to empty list)
extension_data: Optional custom metadata
expiration_time_utc: Optional UTC datetime when the entity should expire
"""
self.conversation_history = conversation_history or []
self.extension_data = extension_data
self.expiration_time_utc = expiration_time_utc
def to_dict(self) -> dict[str, Any]:
result: dict[str, Any] = {
@@ -362,19 +354,13 @@ class DurableAgentStateData:
}
if self.extension_data is not None:
result[DurableStateFields.EXTENSION_DATA] = self.extension_data
if self.expiration_time_utc is not None:
result[DurableStateFields.EXPIRATION_TIME_UTC] = self.expiration_time_utc.isoformat()
return result
@classmethod
def from_dict(cls, data_dict: dict[str, Any]) -> DurableAgentStateData:
expiration_time_raw = data_dict.get(DurableStateFields.EXPIRATION_TIME_UTC)
expiration_time_utc = _parse_created_at(expiration_time_raw) if expiration_time_raw else None
return cls(
conversation_history=_parse_history_entries(data_dict),
extension_data=data_dict.get(DurableStateFields.EXTENSION_DATA),
expiration_time_utc=expiration_time_utc,
)
@@ -463,7 +449,7 @@ class DurableAgentState:
"""Get the count of conversation entries (requests + responses)."""
return len(self.data.conversation_history)
def try_get_agent_response(self, correlation_id: str) -> AgentRunResponse | None:
def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None:
"""Try to get an agent response by correlation ID.
This method searches the conversation history for a response entry matching the given
@@ -485,8 +471,14 @@ class DurableAgentState:
for entry in self.data.conversation_history:
if entry.correlation_id == correlation_id and isinstance(entry, DurableAgentStateResponse):
# Found the entry, extract response data
return DurableAgentStateResponse.to_run_response(entry)
# Get the text content from assistant messages only
content = "\n".join(message.text for message in entry.messages if message.text)
return {
ApiResponseFields.CONTENT: content,
ApiResponseFields.MESSAGE_COUNT: self.message_count,
ApiResponseFields.CORRELATION_ID: correlation_id,
}
return None
@@ -710,21 +702,6 @@ class DurableAgentStateResponse(DurableAgentStateEntry):
usage=DurableAgentStateUsage.from_usage(response.usage_details),
)
@staticmethod
def to_run_response(
response_entry: DurableAgentStateResponse,
) -> AgentRunResponse:
"""Converts a DurableAgentStateResponse back to an AgentRunResponse."""
messages = [m.to_chat_message() for m in response_entry.messages]
usage_details = response_entry.usage.to_usage_details() if response_entry.usage is not None else UsageDetails()
return AgentRunResponse(
created_at=response_entry.created_at.isoformat(),
messages=messages,
usage_details=usage_details,
)
class DurableAgentStateMessage:
"""Represents a message within a conversation history entry.
@@ -1234,24 +1211,14 @@ class DurableAgentStateUsage:
input_token_count=usage.input_token_count,
output_token_count=usage.output_token_count,
total_token_count=usage.total_token_count,
extensionData=usage.additional_counts,
)
def to_usage_details(self) -> UsageDetails:
# Convert back to AI SDK UsageDetails
extension_data: dict[str, int] = {}
if self.extensionData is not None:
for k, v in self.extensionData.items():
try:
extension_data[k] = int(v)
except (ValueError, TypeError):
continue
return UsageDetails(
input_token_count=self.input_token_count,
output_token_count=self.output_token_count,
total_token_count=self.total_token_count,
**extension_data,
)
@@ -8,41 +8,346 @@ allows for long-running agent conversations.
"""
import asyncio
from collections.abc import Callable
import inspect
from collections.abc import AsyncIterable, Callable
from typing import Any, cast
import azure.durable_functions as df
from agent_framework import AgentProtocol, get_logger
from agent_framework_durabletask import (
AgentEntity,
AgentEntityStateProviderMixin,
AgentResponseCallbackProtocol,
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
ChatMessage,
ErrorContent,
Role,
get_logger,
)
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateEntry,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import RunRequest
logger = get_logger("agent_framework.azurefunctions.entities")
class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin):
"""Azure Functions Durable Entity state provider for AgentEntity.
class AgentEntity:
"""Durable entity that manages agent execution and conversation state.
This class utilizes the Durable Entity context from `azure-functions-durable` package
to get and set the state of the agent entity.
This entity:
- Maintains conversation history
- Executes agent with messages
- Stores agent responses
- Handles tool execution
Operations:
- run: Execute the agent with a message
- run_agent: (Deprecated) Execute the agent with a message
- reset: Clear conversation history
Attributes:
agent: The AgentProtocol instance
state: The DurableAgentState managing conversation history
"""
def __init__(self, context: df.DurableEntityContext) -> None:
self._context = context
agent: AgentProtocol
state: DurableAgentState
def _get_state_dict(self) -> dict[str, Any]:
raw_state = self._context.get_state(lambda: {})
if not isinstance(raw_state, dict):
return {}
return cast(dict[str, Any], raw_state)
def __init__(
self,
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
):
"""Initialize the agent entity.
def _set_state_dict(self, state: dict[str, Any]) -> None:
self._context.set_state(state)
Args:
agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol)
callback: Optional callback invoked during streaming updates and final responses
"""
self.agent = agent
self.state = DurableAgentState()
self.callback = callback
def _get_thread_id_from_entity(self) -> str:
return self._context.entity_key
logger.debug(f"[AgentEntity] Initialized with agent type: {type(agent).__name__}")
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
"""Check if a conversation history entry is an error response.
Error responses should be kept in history for tracking but not sent to the agent
since Azure OpenAI doesn't support 'error' content type.
Args:
entry: A conversation history entry (DurableAgentStateEntry or dict)
Returns:
True if the entry is a response containing error content, False otherwise
"""
if isinstance(entry, DurableAgentStateResponse):
return entry.is_error
return False
async def run_agent(
self,
context: df.DurableEntityContext,
request: RunRequest | dict[str, Any] | str,
) -> AgentRunResponse:
"""(Deprecated) Execute the agent with a message directly in the entity.
Args:
context: Entity context
request: RunRequest object, dict, or string message (for backward compatibility)
Returns:
AgentRunResponse enriched with execution metadata.
"""
return await self.run(context, request)
async def run(
self,
context: df.DurableEntityContext,
request: RunRequest | dict[str, Any] | str,
) -> AgentRunResponse:
"""Execute the agent with a message directly in the entity.
Args:
context: Entity context
request: RunRequest object, dict, or string message (for backward compatibility)
Returns:
AgentRunResponse enriched with execution metadata.
"""
if isinstance(request, str):
run_request = RunRequest(message=request, role=Role.USER)
elif isinstance(request, dict):
run_request = RunRequest.from_dict(request)
else:
run_request = request
message = run_request.message
thread_id = run_request.thread_id
correlation_id = run_request.correlation_id
if not thread_id:
raise ValueError("RunRequest must include a thread_id")
if not correlation_id:
raise ValueError("RunRequest must include a correlation_id")
response_format = run_request.response_format
enable_tool_calls = run_request.enable_tool_calls
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
logger.debug(f"[AgentEntity.run] Received Message: {state_request}")
try:
# Build messages from conversation history, excluding error responses
# Error responses are kept in history for tracking but not sent to the agent
chat_messages: list[ChatMessage] = [
m.to_chat_message()
for entry in self.state.data.conversation_history
if not self._is_error_response(entry)
for m in entry.messages
]
run_kwargs: dict[str, Any] = {"messages": chat_messages}
if not enable_tool_calls:
run_kwargs["tools"] = None
if response_format:
run_kwargs["response_format"] = response_format
agent_run_response: AgentRunResponse = await self._invoke_agent(
run_kwargs=run_kwargs,
correlation_id=correlation_id,
thread_id=thread_id,
request_message=message,
)
logger.debug(
"[AgentEntity.run] Agent invocation completed - response type: %s",
type(agent_run_response).__name__,
)
try:
response_text = agent_run_response.text if agent_run_response.text else "No response"
logger.debug(f"Response: {response_text[:100]}...")
except Exception as extraction_error:
logger.error(
"Error extracting response text: %s",
extraction_error,
exc_info=True,
)
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
logger.debug("[AgentEntity.run] AgentRunResponse stored in conversation history")
return agent_run_response
except Exception as exc:
logger.exception("[AgentEntity.run] Agent execution failed.")
# Create error message
error_message = ChatMessage(
role=Role.ASSISTANT, contents=[ErrorContent(message=str(exc), error_code=type(exc).__name__)]
)
error_response = AgentRunResponse(messages=[error_message])
# Create and store error response in conversation history
error_state_response = DurableAgentStateResponse.from_run_response(correlation_id, error_response)
error_state_response.is_error = True
self.state.data.conversation_history.append(error_state_response)
return error_response
async def _invoke_agent(
self,
run_kwargs: dict[str, Any],
correlation_id: str,
thread_id: str,
request_message: str,
) -> AgentRunResponse:
"""Execute the agent, preferring streaming when available."""
callback_context: AgentCallbackContext | None = None
if self.callback is not None:
callback_context = self._build_callback_context(
correlation_id=correlation_id,
thread_id=thread_id,
request_message=request_message,
)
run_stream_callable = getattr(self.agent, "run_stream", None)
if callable(run_stream_callable):
try:
stream_candidate = run_stream_callable(**run_kwargs)
if inspect.isawaitable(stream_candidate):
stream_candidate = await stream_candidate
return await self._consume_stream(
stream=cast(AsyncIterable[AgentRunResponseUpdate], stream_candidate),
callback_context=callback_context,
)
except TypeError as type_error:
if "__aiter__" not in str(type_error):
raise
logger.debug(
"run_stream returned a non-async result; falling back to run(): %s",
type_error,
)
except Exception as stream_error:
logger.warning(
"run_stream failed; falling back to run(): %s",
stream_error,
exc_info=True,
)
else:
logger.debug("Agent does not expose run_stream; falling back to run().")
agent_run_response = await self._invoke_non_stream(run_kwargs)
await self._notify_final_response(agent_run_response, callback_context)
return agent_run_response
async def _consume_stream(
self,
stream: AsyncIterable[AgentRunResponseUpdate],
callback_context: AgentCallbackContext | None = None,
) -> AgentRunResponse:
"""Consume streaming responses and build the final AgentRunResponse."""
updates: list[AgentRunResponseUpdate] = []
async for update in stream:
updates.append(update)
await self._notify_stream_update(update, callback_context)
if updates:
response = AgentRunResponse.from_agent_run_response_updates(updates)
else:
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
response = AgentRunResponse(messages=[])
await self._notify_final_response(response, callback_context)
return response
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentRunResponse:
"""Invoke the agent without streaming support."""
run_callable = getattr(self.agent, "run", None)
if run_callable is None or not callable(run_callable):
raise AttributeError("Agent does not implement run() method")
result = run_callable(**run_kwargs)
if inspect.isawaitable(result):
result = await result
if not isinstance(result, AgentRunResponse):
raise TypeError(f"Agent run() must return an AgentRunResponse instance; received {type(result).__name__}")
return result
async def _notify_stream_update(
self,
update: AgentRunResponseUpdate,
context: AgentCallbackContext | None,
) -> None:
"""Invoke the streaming callback if one is registered."""
if self.callback is None or context is None:
return
try:
callback_result = self.callback.on_streaming_response_update(update, context)
if inspect.isawaitable(callback_result):
await callback_result
except Exception as exc:
logger.warning(
"[AgentEntity] Streaming callback raised an exception: %s",
exc,
exc_info=True,
)
async def _notify_final_response(
self,
response: AgentRunResponse,
context: AgentCallbackContext | None,
) -> None:
"""Invoke the final response callback if one is registered."""
if self.callback is None or context is None:
return
try:
callback_result = self.callback.on_agent_response(response, context)
if inspect.isawaitable(callback_result):
await callback_result
except Exception as exc:
logger.warning(
"[AgentEntity] Response callback raised an exception: %s",
exc,
exc_info=True,
)
def _build_callback_context(
self,
correlation_id: str,
thread_id: str,
request_message: str,
) -> AgentCallbackContext:
"""Create the callback context provided to consumers."""
agent_name = getattr(self.agent, "name", None) or type(self.agent).__name__
return AgentCallbackContext(
agent_name=agent_name,
correlation_id=correlation_id,
thread_id=thread_id,
request_message=request_message,
)
def reset(self, context: df.DurableEntityContext) -> None:
"""Reset the entity state (clear conversation history)."""
logger.debug("[AgentEntity.reset] Resetting entity state")
self.state.data = DurableAgentStateData(conversation_history=[])
logger.debug("[AgentEntity.reset] State reset complete")
def create_agent_entity(
@@ -63,10 +368,19 @@ def create_agent_entity(
"""Async handler that executes the entity operations."""
try:
logger.debug("[entity_function] Entity triggered")
logger.debug("[entity_function] Operation: %s", context.operation_name)
logger.debug(f"[entity_function] Operation: {context.operation_name}")
state_provider = AzureFunctionEntityStateProvider(context)
entity = AgentEntity(agent, callback, state_provider=state_provider)
current_state = context.get_state(lambda: None)
logger.debug("Retrieved state: %s", str(current_state)[:100])
entity = AgentEntity(agent, callback)
if current_state is not None:
entity.state = DurableAgentState.from_dict(current_state)
logger.debug(
"[entity_function] Restored entity from state (message_count: %s)", entity.state.message_count
)
else:
logger.debug("[entity_function] Created new entity instance")
operation = context.operation_name
@@ -80,18 +394,21 @@ def create_agent_entity(
# Fall back to treating input as message string
request = "" if input_data is None else str(cast(object, input_data))
result = await entity.run(request)
result = await entity.run(context, request)
context.set_result(result.to_dict())
elif operation == "reset":
entity.reset()
entity.reset(context)
context.set_result({"status": "reset"})
else:
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {operation}"})
logger.info("[entity_function] Operation %s completed successfully", operation)
serialized_state = entity.state.to_dict()
logger.debug("State dict: %s", serialized_state)
context.set_state(serialized_state)
logger.info(f"[entity_function] Operation {operation} completed successfully")
except Exception as exc:
logger.exception("[entity_function] Error executing entity operation %s", exc)
@@ -8,14 +8,13 @@ This module defines the request and response models used by the framework.
from __future__ import annotations
import inspect
import json
import uuid
from collections.abc import MutableMapping
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib import import_module
from typing import TYPE_CHECKING, Any, cast
import azure.durable_functions as df
from agent_framework import AgentThread, Role
from ._constants import REQUEST_RESPONSE_FORMAT_TEXT
@@ -33,6 +32,187 @@ else:
_PydanticBaseModel = _RuntimeBaseModel
@dataclass
class AgentSessionId:
"""Represents an agent session ID, which is used to identify a long-running agent session.
Attributes:
name: The name of the agent that owns the session (case-insensitive)
key: The unique key of the agent session (case-sensitive)
"""
name: str
key: str
ENTITY_NAME_PREFIX: str = "dafx-"
@staticmethod
def to_entity_name(name: str) -> str:
"""Converts an agent name to an entity name by adding the DAFx prefix.
Args:
name: The agent name
Returns:
The entity name with the dafx- prefix
"""
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
@staticmethod
def with_random_key(name: str) -> AgentSessionId:
"""Creates a new AgentSessionId with the specified name and a randomly generated key.
Args:
name: The name of the agent that owns the session
Returns:
A new AgentSessionId with the specified name and a random GUID key
"""
return AgentSessionId(name=name, key=uuid.uuid4().hex)
def to_entity_id(self) -> df.EntityId:
"""Converts this AgentSessionId to a Durable Functions EntityId.
Returns:
EntityId for use with Durable Functions APIs
"""
return df.EntityId(self.to_entity_name(self.name), self.key)
@staticmethod
def from_entity_id(entity_id: df.EntityId) -> AgentSessionId:
"""Creates an AgentSessionId from a Durable Functions EntityId.
Args:
entity_id: The EntityId to convert
Returns:
AgentSessionId instance
Raises:
ValueError: If the entity ID does not have the expected prefix
"""
if not entity_id.name.startswith(AgentSessionId.ENTITY_NAME_PREFIX):
raise ValueError(
f"'{entity_id}' is not a valid agent session ID. "
f"Expected entity name to start with '{AgentSessionId.ENTITY_NAME_PREFIX}'"
)
agent_name = entity_id.name[len(AgentSessionId.ENTITY_NAME_PREFIX) :]
return AgentSessionId(name=agent_name, key=entity_id.key)
def __str__(self) -> str:
"""Returns a string representation in the form @name@key."""
return f"@{self.name}@{self.key}"
def __repr__(self) -> str:
"""Returns a detailed string representation."""
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
@staticmethod
def parse(session_id_string: str) -> AgentSessionId:
"""Parses a string representation of an agent session ID.
Args:
session_id_string: A string in the form @name@key
Returns:
AgentSessionId instance
Raises:
ValueError: If the string format is invalid
"""
if not session_id_string.startswith("@"):
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
parts = session_id_string[1:].split("@", 1)
if len(parts) != 2:
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
return AgentSessionId(name=parts[0], key=parts[1])
class DurableAgentThread(AgentThread):
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
def __init__(
self,
*,
session_id: AgentSessionId | None = None,
service_thread_id: str | None = None,
message_store: Any = None,
context_provider: Any = None,
) -> None:
super().__init__(
service_thread_id=service_thread_id,
message_store=message_store,
context_provider=context_provider,
)
self._session_id: AgentSessionId | None = session_id
@property
def session_id(self) -> AgentSessionId | None:
"""Returns the durable agent session identifier for this thread."""
return self._session_id
def attach_session(self, session_id: AgentSessionId) -> None:
"""Associates the thread with the provided :class:`AgentSessionId`."""
self._session_id = session_id
@classmethod
def from_session_id(
cls,
session_id: AgentSessionId,
*,
service_thread_id: str | None = None,
message_store: Any = None,
context_provider: Any = None,
) -> DurableAgentThread:
"""Creates a durable thread pre-associated with the supplied session ID."""
return cls(
session_id=session_id,
service_thread_id=service_thread_id,
message_store=message_store,
context_provider=context_provider,
)
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
"""Serializes thread state including the durable session identifier."""
state = await super().serialize(**kwargs)
if self._session_id is not None:
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
return state
@classmethod
async def deserialize(
cls,
serialized_thread_state: MutableMapping[str, Any],
*,
message_store: Any = None,
**kwargs: Any,
) -> DurableAgentThread:
"""Restores a durable thread, rehydrating the stored session identifier."""
state_payload = dict(serialized_thread_state)
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
thread = await super().deserialize(
state_payload,
message_store=message_store,
**kwargs,
)
if not isinstance(thread, DurableAgentThread):
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
if session_id_value is None:
return thread
if not isinstance(session_id_value, str):
raise ValueError("durable_session_id must be a string when present in serialized state")
thread.attach_session(AgentSessionId.parse(session_id_value))
return thread
def serialize_response_format(response_format: type[BaseModel] | None) -> Any:
"""Serialize response format for transport across durable function boundaries."""
if response_format is None:
@@ -104,43 +284,42 @@ class RunRequest:
role: The role of the message sender (user, system, or assistant)
response_format: Optional Pydantic BaseModel type describing the structured response format
enable_tool_calls: Whether to enable tool calls for this request
wait_for_response: If True (default), caller will wait for agent response. If False,
returns immediately after signaling (fire-and-forget mode)
correlation_id: Correlation ID for tracking the response to this specific request
thread_id: Optional thread ID for tracking
correlation_id: Optional correlation ID for tracking the response to this specific request
created_at: Optional timestamp when the request was created
orchestration_id: Optional ID of the orchestration that initiated this request
"""
message: str
request_response_format: str
correlation_id: str
role: Role = Role.USER
response_format: type[BaseModel] | None = None
enable_tool_calls: bool = True
wait_for_response: bool = True
created_at: datetime | None = None
thread_id: str | None = None
correlation_id: str | None = None
created_at: str | None = None
orchestration_id: str | None = None
def __init__(
self,
message: str,
correlation_id: str,
request_response_format: str = REQUEST_RESPONSE_FORMAT_TEXT,
role: Role | str | None = Role.USER,
response_format: type[BaseModel] | None = None,
enable_tool_calls: bool = True,
wait_for_response: bool = True,
created_at: datetime | None = None,
thread_id: str | None = None,
correlation_id: str | None = None,
created_at: str | None = None,
orchestration_id: str | None = None,
) -> None:
self.message = message
self.correlation_id = correlation_id
self.role = self.coerce_role(role)
self.response_format = response_format
self.request_response_format = request_response_format
self.enable_tool_calls = enable_tool_calls
self.wait_for_response = wait_for_response
self.created_at = created_at if created_at is not None else datetime.now(tz=timezone.utc)
self.thread_id = thread_id
self.correlation_id = correlation_id
self.created_at = created_at
self.orchestration_id = orchestration_id
@staticmethod
@@ -160,155 +339,33 @@ class RunRequest:
result = {
"message": self.message,
"enable_tool_calls": self.enable_tool_calls,
"wait_for_response": self.wait_for_response,
"role": self.role.value,
"request_response_format": self.request_response_format,
"correlationId": self.correlation_id,
}
if self.response_format:
result["response_format"] = serialize_response_format(self.response_format)
if self.thread_id:
result["thread_id"] = self.thread_id
if self.correlation_id:
result["correlationId"] = self.correlation_id
if self.created_at:
result["created_at"] = self.created_at.isoformat()
result["created_at"] = self.created_at
if self.orchestration_id:
result["orchestrationId"] = self.orchestration_id
return result
@classmethod
def from_json(cls, data: str) -> RunRequest:
"""Create RunRequest from JSON string."""
try:
dict_data = json.loads(data)
except json.JSONDecodeError as e:
raise ValueError("The durable agent state is not valid JSON.") from e
return cls.from_dict(dict_data)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RunRequest:
"""Create RunRequest from dictionary."""
created_at = data.get("created_at")
if isinstance(created_at, str):
try:
created_at = datetime.fromisoformat(created_at)
except ValueError:
created_at = None
correlation_id = data.get("correlationId")
if not correlation_id:
raise ValueError("correlationId is required in RunRequest data")
return cls(
message=data.get("message", ""),
correlation_id=correlation_id,
request_response_format=data.get("request_response_format", REQUEST_RESPONSE_FORMAT_TEXT),
role=cls.coerce_role(data.get("role")),
response_format=_deserialize_response_format(data.get("response_format")),
wait_for_response=data.get("wait_for_response", True),
enable_tool_calls=data.get("enable_tool_calls", True),
created_at=created_at,
thread_id=data.get("thread_id"),
correlation_id=data.get("correlationId"),
created_at=data.get("created_at"),
orchestration_id=data.get("orchestrationId"),
)
@dataclass
class AgentSessionId:
"""Represents an agent session identifier (name + key)."""
name: str
key: str
ENTITY_NAME_PREFIX: str = "dafx-"
@staticmethod
def to_entity_name(name: str) -> str:
return f"{AgentSessionId.ENTITY_NAME_PREFIX}{name}"
@staticmethod
def with_random_key(name: str) -> AgentSessionId:
return AgentSessionId(name=name, key=uuid.uuid4().hex)
@property
def entity_name(self) -> str:
return self.to_entity_name(self.name)
def __str__(self) -> str:
return f"@{self.name}@{self.key}"
def __repr__(self) -> str:
return f"AgentSessionId(name='{self.name}', key='{self.key}')"
@staticmethod
def parse(session_id_string: str) -> AgentSessionId:
if not session_id_string.startswith("@"):
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
parts = session_id_string[1:].split("@", 1)
if len(parts) != 2:
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
return AgentSessionId(name=parts[0], key=parts[1])
class DurableAgentThread(AgentThread):
"""Durable agent thread that tracks the owning :class:`AgentSessionId`."""
_SERIALIZED_SESSION_ID_KEY = "durable_session_id"
def __init__(
self,
*,
session_id: AgentSessionId | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self._session_id: AgentSessionId | None = session_id
@property
def session_id(self) -> AgentSessionId | None:
return self._session_id
@session_id.setter
def session_id(self, value: AgentSessionId | None) -> None:
self._session_id = value
@classmethod
def from_session_id(
cls,
session_id: AgentSessionId,
**kwargs: Any,
) -> DurableAgentThread:
return cls(session_id=session_id, **kwargs)
async def serialize(self, **kwargs: Any) -> dict[str, Any]:
state = await super().serialize(**kwargs)
if self._session_id is not None:
state[self._SERIALIZED_SESSION_ID_KEY] = str(self._session_id)
return state
@classmethod
async def deserialize(
cls,
serialized_thread_state: MutableMapping[str, Any],
*,
message_store: Any = None,
**kwargs: Any,
) -> DurableAgentThread:
state_payload = dict(serialized_thread_state)
session_id_value = state_payload.pop(cls._SERIALIZED_SESSION_ID_KEY, None)
thread = await super().deserialize(
state_payload,
message_store=message_store,
**kwargs,
)
if not isinstance(thread, DurableAgentThread):
raise TypeError("Deserialized thread is not a DurableAgentThread instance")
if session_id_value is None:
return thread
if not isinstance(session_id_value, str):
raise ValueError("durable_session_id must be a string when present in serialized state")
thread.session_id = AgentSessionId.parse(session_id_value)
return thread
@@ -5,22 +5,24 @@
This module provides support for using agents inside Durable Function orchestrations.
"""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
import uuid
from collections.abc import AsyncIterator, Callable
from typing import TYPE_CHECKING, Any, TypeAlias, cast
import azure.durable_functions as df
from agent_framework import AgentThread, get_logger
from agent_framework_durabletask import (
DurableAgentExecutor,
RunRequest,
ensure_response_format,
load_agent_response,
from agent_framework import (
AgentProtocol,
AgentRunResponse,
AgentRunResponseUpdate,
AgentThread,
ChatMessage,
get_logger,
)
from azure.durable_functions.models import TaskBase
from azure.durable_functions.models.actions.NoOpAction import NoOpAction
from azure.durable_functions.models.Task import CompoundTask, TaskState
from pydantic import BaseModel
from ._models import AgentSessionId, DurableAgentThread, RunRequest
logger = get_logger("agent_framework.azurefunctions.orchestration")
CompoundActionConstructor: TypeAlias = Callable[[list[Any]], Any] | None
@@ -43,25 +45,6 @@ else:
_TypedCompoundTask = CompoundTask
class PreCompletedTask(TaskBase):
"""A simple task that is already completed with a result.
Used for fire-and-forget mode where we want to return immediately
with an acceptance response without waiting for entity processing.
"""
def __init__(self, result: Any):
"""Initialize with a completed result.
Args:
result: The result value for this completed task
"""
# Initialize with a NoOp action since we don't need actual orchestration actions
super().__init__(-1, NoOpAction())
# Immediately mark as completed with the result
self.set_value(is_error=False, value=result)
class AgentTask(_TypedCompoundTask):
"""A custom Task that wraps entity calls and provides typed AgentRunResponse results.
@@ -82,13 +65,10 @@ class AgentTask(_TypedCompoundTask):
response_format: Optional Pydantic model for response parsing
correlation_id: Correlation ID for logging
"""
# Set instance variables BEFORE calling super().__init__
# because super().__init__ may trigger try_set_value for pre-completed tasks
super().__init__([entity_task])
self._response_format = response_format
self._correlation_id = correlation_id
super().__init__([entity_task])
# Override action_repr to expose the inner task's action directly
# This ensures compatibility with ReplaySchema V3 which expects Action objects.
self.action_repr = entity_task.action_repr
@@ -115,10 +95,10 @@ class AgentTask(_TypedCompoundTask):
)
try:
response = load_agent_response(raw_result)
response = self._load_agent_response(raw_result)
if self._response_format is not None:
ensure_response_format(
self._ensure_response_format(
self._response_format,
self._correlation_id,
response,
@@ -138,88 +118,249 @@ class AgentTask(_TypedCompoundTask):
self._first_error = child.result
self.set_value(is_error=True, value=self._first_error)
def _load_agent_response(self, agent_response: AgentRunResponse | dict[str, Any] | None) -> AgentRunResponse:
"""Convert raw payloads into AgentRunResponse instance."""
if agent_response is None:
raise ValueError("agent_response cannot be None")
class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
"""Executor that executes durable agents inside Azure Functions orchestrations."""
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
def __init__(self, context: AgentOrchestrationContextType):
self.context = context
if isinstance(agent_response, AgentRunResponse):
return agent_response
if isinstance(agent_response, dict):
logger.debug("[load_agent_response] Converting dict payload using AgentRunResponse.from_dict")
return AgentRunResponse.from_dict(agent_response)
def generate_unique_id(self) -> str:
return str(self.context.new_uuid())
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
def get_run_request(
def _ensure_response_format(
self,
message: str,
response_format: type[BaseModel] | None,
enable_tool_calls: bool,
wait_for_response: bool = True,
) -> RunRequest:
"""Get the current run request from the orchestration context.
correlation_id: str,
response: AgentRunResponse,
) -> None:
"""Ensure the AgentRunResponse value is parsed into the expected response_format."""
if response_format is not None and not isinstance(response.value, response_format):
response.try_parse_value(response_format)
logger.debug(
"[DurableAIAgent] Loaded AgentRunResponse.value for correlation_id %s with type: %s",
correlation_id,
type(response.value).__name__,
)
class DurableAIAgent(AgentProtocol):
"""A durable agent implementation that uses entity methods to interact with agent entities.
This class implements AgentProtocol and provides methods to work with Azure Durable Functions
orchestrations, which use generators and yield instead of async/await.
Key methods:
- get_new_thread(): Create a new conversation thread
- run(): Execute the agent and return a Task for yielding in orchestrations
Note: The run() method is NOT async. It returns a Task directly that must be
yielded in orchestrations to wait for the entity call to complete.
Example usage in orchestration:
writer = app.get_agent(context, "WriterAgent")
thread = writer.get_new_thread() # NOT yielded - returns immediately
response = yield writer.run( # Yielded - waits for entity call
message="Write a haiku about coding",
thread=thread
)
"""
def __init__(self, context: AgentOrchestrationContextType, agent_name: str):
"""Initialize the DurableAIAgent.
Args:
message: The message to send to the agent
context: The orchestration context
agent_name: Name of the agent (used to construct entity ID)
"""
self.context = context
self.agent_name = agent_name
self._id = str(uuid.uuid4())
self._name = agent_name
self._display_name = agent_name
self._description = f"Durable agent proxy for {agent_name}"
logger.debug("[DurableAIAgent] Initialized for agent: %s", agent_name)
@property
def id(self) -> str:
"""Get the unique identifier for this agent."""
return self._id
@property
def name(self) -> str | None:
"""Get the name of the agent."""
return self._name
@property
def display_name(self) -> str:
"""Get the display name of the agent."""
return self._display_name
@property
def description(self) -> str | None:
"""Get the description of the agent."""
return self._description
# We return an AgentTask here which is a TaskBase subclass.
# This is an intentional deviation from AgentProtocol which defines run() as async.
# The AgentTask can be yielded in Durable Functions orchestrations and will provide
# a typed AgentRunResponse result.
def run( # type: ignore[override]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
response_format: type[BaseModel] | None = None,
**kwargs: Any,
) -> AgentTask:
"""Execute the agent with messages and return an AgentTask for orchestrations.
This method implements AgentProtocol and returns an AgentTask (subclass of TaskBase)
that can be yielded in Durable Functions orchestrations. The task's result will be
a typed AgentRunResponse.
Args:
messages: The message(s) to send to the agent
thread: Optional agent thread for conversation context
response_format: Optional Pydantic model for response parsing
enable_tool_calls: Whether to enable tool calls
wait_for_response: Must be True for orchestration contexts
**kwargs: Additional arguments (enable_tool_calls)
Returns:
RunRequest: The current run request
An AgentTask that resolves to an AgentRunResponse when yielded
Raises:
ValueError: If wait_for_response=False (not supported in orchestrations)
Example:
@app.orchestration_trigger(context_name="context")
def my_orchestration(context):
agent = app.get_agent(context, "MyAgent")
thread = agent.get_new_thread()
response = yield agent.run("Hello", thread=thread)
# response is typed as AgentRunResponse
"""
request = super().get_run_request(
message,
response_format,
enable_tool_calls,
wait_for_response,
)
request.orchestration_id = self.context.instance_id
return request
message_str = self._normalize_messages(messages)
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> AgentTask:
# Extract optional parameters from kwargs
enable_tool_calls = kwargs.get("enable_tool_calls", True)
# Resolve session
session_id = self._create_session_id(agent_name, thread)
# Get the session ID for the entity
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
session_id = thread.session_id
else:
# Create a unique session ID for each call when no thread is provided
# This ensures each call gets its own conversation context
session_key = str(self.context.new_uuid())
session_id = AgentSessionId(name=self.agent_name, key=session_key)
logger.debug("[DurableAIAgent] No thread provided, created unique session_id: %s", session_id)
entity_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
# Create entity ID from session ID
entity_id = session_id.to_entity_id()
# Generate a deterministic correlation ID for this call
# This is required by the entity and must be unique per call
correlation_id = str(self.context.new_uuid())
logger.debug(
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
run_request.correlation_id,
"[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s",
correlation_id,
entity_id,
session_id,
)
# Branch based on wait_for_response
if not run_request.wait_for_response:
# Fire-and-forget mode: signal entity and return pre-completed task
logger.debug(
"[AzureFunctionsAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
run_request.correlation_id,
)
self.context.signal_entity(entity_id, "run", run_request.to_dict())
# Create acceptance response using base class helper
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
# Create a pre-completed task with the acceptance response
entity_task = PreCompletedTask(acceptance_response)
else:
# Blocking mode: call entity and wait for response
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
return AgentTask(
entity_task=entity_task,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
# Prepare the request using RunRequest model
# Include the orchestration's instance_id so it can be stored in the agent's entity state
run_request = RunRequest(
message=message_str,
enable_tool_calls=enable_tool_calls,
correlation_id=correlation_id,
thread_id=session_id.key,
response_format=response_format,
orchestration_id=self.context.instance_id,
)
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
# Call the entity to get the underlying task
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
# Wrap it in an AgentTask that will convert the result to AgentRunResponse
agent_task = AgentTask(
entity_task=entity_task,
response_format=response_format,
correlation_id=correlation_id,
)
logger.debug(
"[DurableAIAgent] Created AgentTask for correlation_id %s",
correlation_id,
)
return agent_task
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterator[AgentRunResponseUpdate]:
"""Run the agent with streaming (not supported for durable agents).
Raises:
NotImplementedError: Streaming is not supported for durable agents.
"""
raise NotImplementedError("Streaming is not supported for durable agents in orchestrations.")
def get_new_thread(self, **kwargs: Any) -> AgentThread:
"""Create a new agent thread for this orchestration instance.
Each call creates a unique thread with its own conversation context.
The session ID is deterministic (uses context.new_uuid()) to ensure
orchestration replay works correctly.
Returns:
A new AgentThread instance with a unique session ID
"""
# Generate a deterministic unique key for this thread
# Using context.new_uuid() ensures the same GUID is generated during replay
session_key = str(self.context.new_uuid())
# Create AgentSessionId with agent name and session key
session_id = AgentSessionId(name=self.agent_name, key=session_key)
thread = DurableAgentThread.from_session_id(session_id, **kwargs)
logger.debug("[DurableAIAgent] Created new thread with session_id: %s", session_id)
return thread
def _messages_to_string(self, messages: list[ChatMessage]) -> str:
"""Convert a list of ChatMessage objects to a single string.
Args:
messages: List of ChatMessage objects
Returns:
Concatenated string of message contents
"""
return "\n".join([msg.text or "" for msg in messages])
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
"""Convert supported message inputs to a single string."""
if messages is None:
return ""
if isinstance(messages, str):
return messages
if isinstance(messages, ChatMessage):
return messages.text or ""
if isinstance(messages, list):
if not messages:
return ""
first_item = messages[0]
if isinstance(first_item, str):
return "\n".join(cast(list[str], messages))
return self._messages_to_string(cast(list[ChatMessage], messages))
return str(messages)
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,6 @@ classifiers = [
]
dependencies = [
"agent-framework-core",
"agent-framework-durabletask",
"azure-functions",
"azure-functions-durable",
]
@@ -15,7 +15,8 @@ Usage:
"""
import pytest
from agent_framework_durabletask import THREAD_ID_HEADER
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
from .testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
@@ -0,0 +1,102 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Callbacks Sample
Tests the callbacks sample for event tracking and management.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_callbacks.py -v
"""
from typing import Any
import pytest
import requests
from .testutils import (
TIMEOUT,
SampleTestHelper,
skip_if_azure_functions_integration_tests_disabled,
)
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("03_callbacks"),
pytest.mark.usefixtures("function_app_for_test"),
skip_if_azure_functions_integration_tests_disabled,
]
class TestSampleCallbacks:
"""Tests for 03_callbacks sample."""
@pytest.fixture(autouse=True)
def _set_base_url(self, base_url: str) -> None:
"""Provide the callback agent base URL for each test."""
self.base_url = f"{base_url}/api/agents/CallbackAgent"
@staticmethod
def _wait_for_callback_events(base_url: str, thread_id: str) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
response = SampleTestHelper.get(f"{base_url}/callbacks/{thread_id}")
if response.status_code == 200:
events = response.json()
return events
def test_agent_with_callbacks(self) -> None:
"""Test agent execution with callback tracking."""
thread_id = "test-callback"
response = SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Tell me about Python", "thread_id": thread_id},
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
events = self._wait_for_callback_events(self.base_url, thread_id)
assert events
assert any(event.get("event_type") == "final" for event in events)
def test_get_callbacks(self) -> None:
"""Test retrieving callback events."""
thread_id = "test-callback-retrieve"
# Send a message first
SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Hello", "thread_id": thread_id, "wait_for_response": False},
)
# Get callbacks
response = SampleTestHelper.get(f"{self.base_url}/callbacks/{thread_id}")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_delete_callbacks(self) -> None:
"""Test clearing callback events."""
thread_id = "test-callback-delete"
# Send a message first
SampleTestHelper.post_json(
f"{self.base_url}/run",
{"message": "Test", "thread_id": thread_id, "wait_for_response": False},
)
# Delete callbacks
response = requests.delete(f"{self.base_url}/callbacks/{thread_id}", timeout=TIMEOUT)
assert response.status_code == 204
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -1,125 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
Integration Tests for Reliable Streaming Sample
Tests the reliable streaming sample using Redis Streams for persistent message delivery.
The function app is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/azurefunctions/tests/integration_tests/.env.example)
- Azurite or Azure Storage account configured
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
Usage:
uv run pytest packages/azurefunctions/tests/integration_tests/test_03_reliable_streaming.py -v
"""
import time
import pytest
import requests
from .testutils import (
SampleTestHelper,
skip_if_azure_functions_integration_tests_disabled,
)
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("03_reliable_streaming"),
pytest.mark.usefixtures("function_app_for_test"),
skip_if_azure_functions_integration_tests_disabled,
]
class TestSampleReliableStreaming:
"""Tests for 03_reliable_streaming sample."""
@pytest.fixture(autouse=True)
def _set_base_url(self, base_url: str) -> None:
"""Provide the base URL for each test."""
self.base_url = base_url
self.agent_url = f"{base_url}/api/agents/TravelPlanner"
self.stream_url = f"{base_url}/api/agent/stream"
def test_agent_run_and_stream(self) -> None:
"""Test agent execution with Redis streaming."""
# Start agent run
response = SampleTestHelper.post_json(
f"{self.agent_url}/run",
{"message": "Plan a 1-day trip to Seattle in 1 sentence", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait a moment for the agent to start writing to Redis
time.sleep(2)
# Stream response from Redis with shorter timeout
# Note: We use text/plain to avoid SSE parsing complexity
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/plain"},
timeout=30, # Shorter timeout for test
)
assert stream_response.status_code == 200
def test_stream_with_sse_format(self) -> None:
"""Test streaming with Server-Sent Events format."""
# Start agent run
response = SampleTestHelper.post_json(
f"{self.agent_url}/run",
{"message": "What's the weather like?", "wait_for_response": False},
)
assert response.status_code == 202
data = response.json()
thread_id = data.get("thread_id")
# Wait for agent to start writing
time.sleep(2)
# Stream with SSE format
stream_response = requests.get(
f"{self.stream_url}/{thread_id}",
headers={"Accept": "text/event-stream"},
timeout=30, # Shorter timeout
)
assert stream_response.status_code == 200
content_type = stream_response.headers.get("content-type", "")
assert "text/event-stream" in content_type
# Check for SSE event markers if we got content
content = stream_response.text
if content:
assert "event:" in content or "data:" in content
def test_stream_nonexistent_conversation(self) -> None:
"""Test streaming from a non-existent conversation.
The endpoint will wait for data in Redis, but since the conversation
doesn't exist, it will timeout. This is expected behavior.
"""
fake_id = "nonexistent-conversation-12345"
# Should timeout since the conversation doesn't exist
with pytest.raises(requests.exceptions.ReadTimeout):
requests.get(
f"{self.stream_url}/{fake_id}",
headers={"Accept": "text/plain"},
timeout=10, # Short timeout for non-existent ID
)
def test_health_endpoint(self) -> None:
"""Test health check endpoint."""
response = SampleTestHelper.get(f"{self.base_url}/api/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "agents" in data
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -2,8 +2,6 @@
"""Unit tests for AgentFunctionApp."""
# pyright: reportPrivateUsage=false
import json
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
@@ -13,42 +11,20 @@ import azure.durable_functions as df
import azure.functions as func
import pytest
from agent_framework import AgentRunResponse, ChatMessage, ErrorContent
from agent_framework_durabletask import (
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._app import WAIT_FOR_RESPONSE_FIELD, WAIT_FOR_RESPONSE_HEADER
from agent_framework_azurefunctions._constants import (
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
THREAD_ID_HEADER,
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
AgentEntity,
AgentEntityStateProviderMixin,
DurableAgentState,
)
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._entities import create_agent_entity
from agent_framework_azurefunctions._durable_agent_state import DurableAgentState
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
def _identity_decorator(func: TFunc) -> TFunc:
return func
class _InMemoryStateProvider(AgentEntityStateProviderMixin):
def __init__(self, *, thread_id: str = "test-thread", initial_state: dict[str, Any] | None = None) -> None:
self._thread_id = thread_id
self._state_dict: dict[str, Any] = initial_state or {}
def _get_state_dict(self) -> dict[str, Any]:
return self._state_dict
def _set_state_dict(self, state: dict[str, Any]) -> None:
self._state_dict = state
def _get_thread_id_from_entity(self) -> str:
return self._thread_id
class TestAgentFunctionAppInit:
"""Test suite for AgentFunctionApp initialization."""
@@ -112,7 +88,7 @@ class TestAgentFunctionAppInit:
app.add_agent(mock_agent, callback=specific_callback)
setup_mock.assert_called_once()
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
assert passed_callback is specific_callback
assert enable_http_endpoint is True
@@ -128,7 +104,7 @@ class TestAgentFunctionAppInit:
app.add_agent(mock_agent)
setup_mock.assert_called_once()
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
assert passed_callback is default_callback
assert enable_http_endpoint is True
@@ -143,7 +119,7 @@ class TestAgentFunctionAppInit:
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
setup_mock.assert_called_once()
_, _, passed_callback, enable_http_endpoint, _enable_mcp_tool_trigger = setup_mock.call_args[0]
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
assert passed_callback is default_callback
assert enable_http_endpoint is True
@@ -359,12 +335,13 @@ class TestAgentEntityOperations:
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Test response")])
)
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run({
"message": "Test message",
"correlationId": "corr-app-entity-1",
})
result = await entity.run(
mock_context,
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
)
assert isinstance(result, AgentRunResponse)
assert result.text == "Test response"
@@ -377,17 +354,22 @@ class TestAgentEntityOperations:
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
)
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Send first message
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-2"})
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
)
# Each conversation turn creates 2 entries: request and response
history = entity.state.data.conversation_history[0].messages # Request entry
assert len(history) == 1 # Just the user message
# Send second message
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"})
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
)
# Now we have 4 entries total (2 requests + 2 responses)
# Access the first request entry
@@ -411,26 +393,32 @@ class TestAgentEntityOperations:
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
)
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert len(entity.state.data.conversation_history) == 0
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"})
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
)
assert len(entity.state.data.conversation_history) == 2
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-3b"})
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
)
assert len(entity.state.data.conversation_history) == 4
def test_entity_reset(self) -> None:
"""Test that entity reset clears state."""
mock_agent = Mock()
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider())
entity = AgentEntity(mock_agent)
# Set some state
entity.state = DurableAgentState()
# Reset
entity.reset()
mock_context = Mock()
entity.reset(mock_context)
assert len(entity.state.data.conversation_history) == 0
@@ -459,6 +447,7 @@ class TestAgentEntityFactory:
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlationId": "corr-app-factory-1",
}
mock_context.get_state.return_value = None
@@ -486,6 +475,7 @@ class TestAgentEntityFactory:
mock_context.operation_name = "run_agent"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlationId": "corr-app-factory-1",
}
mock_context.get_state.return_value = None
@@ -605,11 +595,7 @@ class TestAgentEntityFactory:
}
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"correlationId": "corr-restore-1",
}
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
@@ -626,12 +612,12 @@ class TestErrorHandling:
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run({
"message": "Test message",
"correlationId": "corr-app-error-1",
})
result = await entity.run(
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
)
assert isinstance(result, AgentRunResponse)
assert len(result.messages) == 1
@@ -724,7 +710,7 @@ class TestIncomingRequestParsing:
request = Mock()
request.params = {"thread_id": "query-thread"}
req_body: dict[str, Any] = {}
req_body = {}
thread_id = app._resolve_thread_id(request, req_body)
@@ -791,7 +777,7 @@ class TestHttpRunRoute:
assert run_request["message"] == "Plain text via HTTP"
assert run_request["role"] == "user"
assert "thread_id" not in run_request
assert "thread_id" in run_request
async def test_http_run_accept_header_returns_json(self) -> None:
"""Test that Accept header requesting JSON results in JSON response."""
@@ -927,9 +913,9 @@ class TestMCPToolEndpoint:
patch.object(app, "durable_client_input") as client_mock,
):
# Setup mock decorator chain
func_name_mock.return_value = _identity_decorator
mcp_trigger_mock.return_value = _identity_decorator
client_mock.return_value = _identity_decorator
func_name_mock.return_value = lambda f: f
mcp_trigger_mock.return_value = lambda f: f
client_mock.return_value = lambda f: f
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
@@ -952,11 +938,11 @@ class TestMCPToolEndpoint:
app = AgentFunctionApp()
with (
patch.object(app, "function_name", return_value=_identity_decorator),
patch.object(app, "function_name", return_value=lambda f: f),
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
patch.object(app, "durable_client_input", return_value=_identity_decorator),
patch.object(app, "durable_client_input", return_value=lambda f: f),
):
mcp_trigger_mock.return_value = _identity_decorator
mcp_trigger_mock.return_value = lambda f: f
app._setup_mcp_tool_trigger(mock_agent.name, None)
@@ -1078,10 +1064,10 @@ class TestMCPToolEndpoint:
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
# Capture the health check handler function
captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None
captured_handler = None
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
def decorator(func: TFunc) -> TFunc:
def capture_decorator(*args, **kwargs):
def decorator(func):
nonlocal captured_handler
captured_handler = func
return func
@@ -1,22 +1,42 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for create_agent_entity factory function.
"""Unit tests for AgentEntity and entity operations.
Run with: pytest tests/test_entities.py -v
"""
from collections.abc import Callable
import asyncio
from collections.abc import AsyncIterator, Callable
from datetime import datetime
from typing import Any, TypeVar
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agent_framework import AgentRunResponse, ChatMessage
from agent_framework import AgentRunResponse, AgentRunResponseUpdate, ChatMessage, ErrorContent, Role
from pydantic import BaseModel
from agent_framework_azurefunctions._entities import create_agent_entity
from agent_framework_azurefunctions._durable_agent_state import (
DurableAgentState,
DurableAgentStateData,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateTextContent,
)
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
from agent_framework_azurefunctions._models import RunRequest
TFunc = TypeVar("TFunc", bound=Callable[..., Any])
def _role_value(chat_message: DurableAgentStateMessage) -> str:
"""Helper to extract the string role from a ChatMessage."""
role = getattr(chat_message, "role", None)
role_value = getattr(role, "value", role)
if role_value is None:
return ""
return str(role_value)
def _agent_response(text: str | None) -> AgentRunResponse:
"""Create an AgentRunResponse with a single assistant message."""
message = (
@@ -25,6 +45,379 @@ def _agent_response(text: str | None) -> AgentRunResponse:
return AgentRunResponse(messages=[message])
class RecordingCallback:
"""Callback implementation capturing streaming and final responses for assertions."""
def __init__(self):
self.stream_mock = AsyncMock()
self.response_mock = AsyncMock()
async def on_streaming_response_update(
self,
update: AgentRunResponseUpdate,
context: Any,
) -> None:
await self.stream_mock(update, context)
async def on_agent_response(self, response: AgentRunResponse, context: Any) -> None:
await self.response_mock(response, context)
class EntityStructuredResponse(BaseModel):
answer: float
class TestAgentEntityInit:
"""Test suite for AgentEntity initialization."""
def test_init_creates_entity(self) -> None:
"""Test that AgentEntity initializes correctly."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
assert entity.agent == mock_agent
assert len(entity.state.data.conversation_history) == 0
assert entity.state.data.extension_data is None
assert entity.state.schema_version == DurableAgentState.SCHEMA_VERSION
def test_init_stores_agent_reference(self) -> None:
"""Test that the agent reference is stored correctly."""
mock_agent = Mock()
mock_agent.name = "TestAgent"
entity = AgentEntity(mock_agent)
assert entity.agent.name == "TestAgent"
def test_init_with_different_agent_types(self) -> None:
"""Test initialization with different agent types."""
agent1 = Mock()
agent1.__class__.__name__ = "AzureOpenAIAgent"
agent2 = Mock()
agent2.__class__.__name__ = "CustomAgent"
entity1 = AgentEntity(agent1)
entity2 = AgentEntity(agent2)
assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent"
assert entity2.agent.__class__.__name__ == "CustomAgent"
class TestAgentEntityRunAgent:
"""Test suite for the run_agent operation."""
async def test_run_executes_agent(self) -> None:
"""Test that run executes the agent."""
mock_agent = Mock()
mock_response = _agent_response("Test response")
mock_agent.run = AsyncMock(return_value=mock_response)
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
)
# Verify agent.run was called
mock_agent.run.assert_called_once()
_, kwargs = mock_agent.run.call_args
sent_messages: list[Any] = kwargs.get("messages")
assert len(sent_messages) == 1
sent_message = sent_messages[0]
assert isinstance(sent_message, ChatMessage)
assert getattr(sent_message, "text", None) == "Test message"
assert getattr(sent_message.role, "value", sent_message.role) == "user"
# Verify result
assert isinstance(result, AgentRunResponse)
assert result.text == "Test response"
async def test_run_agent_executes_agent(self) -> None:
"""Test that run_agent executes the agent."""
mock_agent = Mock()
mock_response = _agent_response("Test response")
mock_agent.run = AsyncMock(return_value=mock_response)
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
)
# Verify agent.run was called
mock_agent.run.assert_called_once()
_, kwargs = mock_agent.run.call_args
sent_messages: list[Any] = kwargs.get("messages")
assert len(sent_messages) == 1
sent_message = sent_messages[0]
assert isinstance(sent_message, ChatMessage)
assert getattr(sent_message, "text", None) == "Test message"
assert getattr(sent_message.role, "value", sent_message.role) == "user"
# Verify result
assert isinstance(result, AgentRunResponse)
assert result.text == "Test response"
async def test_run_agent_streaming_callbacks_invoked(self) -> None:
"""Ensure streaming updates trigger callbacks and run() is not used."""
updates = [
AgentRunResponseUpdate(text="Hello"),
AgentRunResponseUpdate(text=" world"),
]
async def update_generator() -> AsyncIterator[AgentRunResponseUpdate]:
for update in updates:
yield update
mock_agent = Mock()
mock_agent.name = "StreamingAgent"
mock_agent.run_stream = Mock(return_value=update_generator())
mock_agent.run = AsyncMock(side_effect=AssertionError("run() should not be called when streaming succeeds"))
callback = RecordingCallback()
entity = AgentEntity(mock_agent, callback=callback)
mock_context = Mock()
result = await entity.run(
mock_context,
{
"message": "Tell me something",
"thread_id": "session-1",
"correlationId": "corr-stream-1",
},
)
assert isinstance(result, AgentRunResponse)
assert "Hello" in result.text
assert callback.stream_mock.await_count == len(updates)
assert callback.response_mock.await_count == 1
mock_agent.run.assert_not_called()
# Validate callback arguments
stream_calls = callback.stream_mock.await_args_list
for expected_update, recorded_call in zip(updates, stream_calls, strict=True):
assert recorded_call.args[0] is expected_update
context = recorded_call.args[1]
assert context.agent_name == "StreamingAgent"
assert context.correlation_id == "corr-stream-1"
assert context.thread_id == "session-1"
assert context.request_message == "Tell me something"
final_call = callback.response_mock.await_args
assert final_call is not None
final_response, final_context = final_call.args
assert final_context.agent_name == "StreamingAgent"
assert final_context.correlation_id == "corr-stream-1"
assert final_context.thread_id == "session-1"
assert final_context.request_message == "Tell me something"
assert getattr(final_response, "text", "").strip()
async def test_run_agent_final_callback_without_streaming(self) -> None:
"""Ensure the final callback fires even when streaming is unavailable."""
mock_agent = Mock()
mock_agent.name = "NonStreamingAgent"
mock_agent.run_stream = None
agent_response = _agent_response("Final response")
mock_agent.run = AsyncMock(return_value=agent_response)
callback = RecordingCallback()
entity = AgentEntity(mock_agent, callback=callback)
mock_context = Mock()
result = await entity.run(
mock_context,
{
"message": "Hi",
"thread_id": "session-2",
"correlationId": "corr-final-1",
},
)
assert isinstance(result, AgentRunResponse)
assert result.text == "Final response"
assert callback.stream_mock.await_count == 0
assert callback.response_mock.await_count == 1
final_call = callback.response_mock.await_args
assert final_call is not None
assert final_call.args[0] is agent_response
final_context = final_call.args[1]
assert final_context.agent_name == "NonStreamingAgent"
assert final_context.correlation_id == "corr-final-1"
assert final_context.thread_id == "session-2"
assert final_context.request_message == "Hi"
async def test_run_agent_updates_conversation_history(self) -> None:
"""Test that run_agent updates the conversation history."""
mock_agent = Mock()
mock_response = _agent_response("Agent response")
mock_agent.run = AsyncMock(return_value=mock_response)
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run(
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
)
# Should have 1 entry: user message + assistant response
user_history = entity.state.data.conversation_history[0].messages
assistant_history = entity.state.data.conversation_history[1].messages
assert len(user_history) == 1
user_msg = user_history[0]
assert _role_value(user_msg) == "user"
assert user_msg.text == "User message"
assistant_msg = assistant_history[0]
assert _role_value(assistant_msg) == "assistant"
assert assistant_msg.text == "Agent response"
async def test_run_agent_increments_message_count(self) -> None:
"""Test that run_agent increments the message count."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
assert len(entity.state.data.conversation_history) == 0
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
)
assert len(entity.state.data.conversation_history) == 2
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
)
assert len(entity.state.data.conversation_history) == 4
await entity.run(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
)
assert len(entity.state.data.conversation_history) == 6
async def test_run_agent_with_none_thread_id(self) -> None:
"""Test run_agent with a None thread identifier."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
with pytest.raises(ValueError, match="thread_id"):
await entity.run(mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"})
async def test_run_agent_multiple_conversations(self) -> None:
"""Test that run_agent maintains history across multiple messages."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Send multiple messages
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
)
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
)
await entity.run(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
)
history = entity.state.data.conversation_history
assert len(history) == 6
assert entity.state.message_count == 6
class TestAgentEntityReset:
"""Test suite for the reset operation."""
def test_reset_clears_conversation_history(self) -> None:
"""Test that reset clears the conversation history."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
# Add some history with proper DurableAgentStateEntry objects
entity.state.data.conversation_history = [
DurableAgentStateRequest(
correlation_id="test-1",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="msg1")],
)
],
),
]
mock_context = Mock()
entity.reset(mock_context)
assert entity.state.data.conversation_history == []
def test_reset_with_extension_data(self) -> None:
"""Test that reset works when entity has extension data."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
# Set up some initial state with conversation history
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
mock_context = Mock()
entity.reset(mock_context)
assert len(entity.state.data.conversation_history) == 0
def test_reset_clears_message_count(self) -> None:
"""Test that reset clears the message count."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
mock_context = Mock()
entity.reset(mock_context)
assert len(entity.state.data.conversation_history) == 0
async def test_reset_after_conversation(self) -> None:
"""Test reset after a full conversation."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Have a conversation
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
)
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
)
# Verify state before reset
assert entity.state.message_count == 4
assert len(entity.state.data.conversation_history) == 4
# Reset
entity.reset(mock_context)
# Verify state after reset
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
class TestCreateAgentEntity:
"""Test suite for the create_agent_entity factory function."""
@@ -46,9 +439,9 @@ class TestCreateAgentEntity:
# Mock context
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.entity_key = "conv-123"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlationId": "corr-entity-factory",
}
mock_context.get_state.return_value = None
@@ -142,7 +535,7 @@ class TestCreateAgentEntity:
assert state["data"] == {"conversationHistory": []}
def test_entity_function_restores_existing_state(self) -> None:
"""Test that the entity function can operate when existing state is present."""
"""Test that the entity function restores existing state."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
@@ -191,14 +584,482 @@ class TestCreateAgentEntity:
mock_context.operation_name = "reset"
mock_context.get_state.return_value = existing_state
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
entity_function(mock_context)
from_dict_mock.assert_called_once_with(existing_state)
class TestErrorHandling:
"""Test suite for error handling in entities."""
async def test_run_agent_handles_agent_exception(self) -> None:
"""Test that run_agent handles agent exceptions."""
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=Exception("Agent failed"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
)
assert isinstance(result, AgentRunResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert "Agent failed" in (content.message or "")
assert content.error_code == "Exception"
async def test_run_agent_handles_value_error(self) -> None:
"""Test that run_agent handles ValueError instances."""
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=ValueError("Invalid input"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
)
assert isinstance(result, AgentRunResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.error_code == "ValueError"
assert "Invalid input" in str(content.message)
async def test_run_agent_handles_timeout_error(self) -> None:
"""Test that run_agent handles TimeoutError instances."""
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=TimeoutError("Request timeout"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
)
assert isinstance(result, AgentRunResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
assert content.error_code == "TimeoutError"
def test_entity_function_handles_exception_in_operation(self) -> None:
"""Test that the entity function handles exceptions gracefully."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.get_input.side_effect = Exception("Input error")
mock_context.get_state.return_value = None
# Execute - should not raise
entity_function(mock_context)
# Verify error was set
assert mock_context.set_result.called
result = mock_context.set_result.call_args[0][0]
assert "error" in result
# Reset should clear history and persist via set_state
assert mock_context.set_state.called
persisted_state = mock_context.set_state.call_args[0][0]
assert persisted_state["data"]["conversationHistory"] == []
async def test_run_agent_preserves_message_on_error(self) -> None:
"""Test that run_agent preserves message information on error."""
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=Exception("Error"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context,
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
)
# Even on error, message info should be preserved
assert isinstance(result, AgentRunResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, ErrorContent)
class TestConversationHistory:
"""Test suite for conversation history tracking."""
async def test_conversation_history_has_timestamps(self) -> None:
"""Test that conversation history entries include timestamps."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
)
# Check both user and assistant messages have timestamps
for entry in entity.state.data.conversation_history:
timestamp = entry.created_at
assert timestamp is not None
# Verify timestamp is in ISO format
datetime.fromisoformat(str(timestamp))
async def test_conversation_history_ordering(self) -> None:
"""Test that conversation history maintains the correct order."""
mock_agent = Mock()
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Send multiple messages with different responses
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
await entity.run(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
await entity.run(
mock_context,
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
)
# Verify order
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].text == "Message 1" # Request 1
assert history[1].messages[0].text == "Response 1" # Response 1
assert history[2].messages[0].text == "Message 2" # Request 2
assert history[3].messages[0].text == "Response 2" # Response 2
assert history[4].messages[0].text == "Message 3" # Request 3
assert history[5].messages[0].text == "Response 3" # Response 3
async def test_conversation_history_role_alternation(self) -> None:
"""Test that conversation history alternates between user and assistant roles."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
)
await entity.run(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
)
# Check role alternation
history = entity.state.data.conversation_history
# Each conversation turn creates 2 entries: request and response
assert history[0].messages[0].role == "user" # Request 1
assert history[1].messages[0].role == "assistant" # Response 1
assert history[2].messages[0].role == "user" # Request 2
assert history[3].messages[0].role == "assistant" # Response 2
class TestRunRequestSupport:
"""Test suite for RunRequest support in entities."""
async def test_run_agent_with_run_request_object(self) -> None:
"""Test run_agent with a RunRequest object."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
request = RunRequest(
message="Test message",
thread_id="conv-123",
role=Role.USER,
enable_tool_calls=True,
correlation_id="corr-runreq-1",
)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
assert result.text == "Response"
async def test_run_agent_with_dict_request(self) -> None:
"""Test run_agent with a dictionary request."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
request_dict = {
"message": "Test message",
"thread_id": "conv-456",
"role": "system",
"enable_tool_calls": False,
"correlationId": "corr-runreq-2",
}
result = await entity.run(mock_context, request_dict)
assert isinstance(result, AgentRunResponse)
assert result.text == "Response"
async def test_run_agent_with_string_raises_without_correlation(self) -> None:
"""Test that run_agent rejects legacy string input without correlation ID."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
with pytest.raises(ValueError):
await entity.run(mock_context, "Simple message")
async def test_run_agent_stores_role_in_history(self) -> None:
"""Test that run_agent stores the role in conversation history."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
# Send as system role
request = RunRequest(
message="System message",
thread_id="conv-runreq-3",
role=Role.SYSTEM,
correlation_id="corr-runreq-3",
)
await entity.run(mock_context, request)
# Check that system role was stored
history = entity.state.data.conversation_history
assert history[0].messages[0].role == "system"
assert history[0].messages[0].text == "System message"
async def test_run_agent_with_response_format(self) -> None:
"""Test run_agent with a JSON response format."""
mock_agent = Mock()
# Return JSON response
mock_agent.run = AsyncMock(return_value=_agent_response('{"answer": 42}'))
entity = AgentEntity(mock_agent)
mock_context = Mock()
request = RunRequest(
message="What is the answer?",
thread_id="conv-runreq-4",
response_format=EntityStructuredResponse,
correlation_id="corr-runreq-4",
)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
assert result.text == '{"answer": 42}'
assert result.value is None
async def test_run_agent_disable_tool_calls(self) -> None:
"""Test run_agent with tool calls disabled."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
request = RunRequest(
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
# Agent should have been called (tool disabling is framework-dependent)
mock_agent.run.assert_called_once()
async def test_entity_function_with_run_request_dict(self) -> None:
"""Test that the entity function handles the RunRequest dict format."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-789",
"role": "user",
"enable_tool_calls": True,
"correlationId": "corr-runreq-6",
}
mock_context.get_state.return_value = None
await asyncio.to_thread(entity_function, mock_context)
# Verify result was set
assert mock_context.set_result.called
result = mock_context.set_result.call_args[0][0]
assert isinstance(result, dict)
# Check if messages are present
assert "messages" in result
assert len(result["messages"]) > 0
message = result["messages"][0]
# Check for text in various possible locations
text_found = False
if "text" in message and message["text"] == "Response":
text_found = True
elif "contents" in message:
for content in message["contents"]:
if isinstance(content, dict) and content.get("text") == "Response":
text_found = True
break
assert text_found, f"Response text not found in message: {message}"
class TestDurableAgentStateRequestOrchestrationId:
"""Test suite for DurableAgentStateRequest orchestration_id field."""
def test_request_with_orchestration_id(self) -> None:
"""Test creating a request with an orchestration_id."""
request = DurableAgentStateRequest(
correlation_id="corr-123",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="test")],
)
],
orchestration_id="orch-456",
)
assert request.orchestration_id == "orch-456"
def test_request_to_dict_includes_orchestration_id(self) -> None:
"""Test that to_dict includes orchestrationId when set."""
request = DurableAgentStateRequest(
correlation_id="corr-123",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="test")],
)
],
orchestration_id="orch-789",
)
data = request.to_dict()
assert "orchestrationId" in data
assert data["orchestrationId"] == "orch-789"
def test_request_to_dict_excludes_orchestration_id_when_none(self) -> None:
"""Test that to_dict excludes orchestrationId when not set."""
request = DurableAgentStateRequest(
correlation_id="corr-123",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="test")],
)
],
)
data = request.to_dict()
assert "orchestrationId" not in data
def test_request_from_dict_with_orchestration_id(self) -> None:
"""Test from_dict correctly parses orchestrationId."""
data = {
"$type": "request",
"correlationId": "corr-123",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "test"}]}],
"orchestrationId": "orch-from-dict",
}
request = DurableAgentStateRequest.from_dict(data)
assert request.orchestration_id == "orch-from-dict"
def test_request_from_run_request_with_orchestration_id(self) -> None:
"""Test from_run_request correctly transfers orchestration_id."""
run_request = RunRequest(
message="test message",
correlation_id="corr-run",
orchestration_id="orch-from-run-request",
)
durable_request = DurableAgentStateRequest.from_run_request(run_request)
assert durable_request.orchestration_id == "orch-from-run-request"
def test_request_from_run_request_without_orchestration_id(self) -> None:
"""Test from_run_request correctly handles missing orchestration_id."""
run_request = RunRequest(
message="test message",
correlation_id="corr-run",
)
durable_request = DurableAgentStateRequest.from_run_request(run_request)
assert durable_request.orchestration_id is None
class TestDurableAgentStateMessageCreatedAt:
"""Test suite for DurableAgentStateMessage created_at field handling."""
def test_message_from_run_request_without_created_at_preserves_none(self) -> None:
"""Test from_run_request preserves None created_at instead of defaulting to current time.
When a RunRequest has no created_at value, the resulting DurableAgentStateMessage
should also have None for created_at, not default to current UTC time.
"""
run_request = RunRequest(
message="test message",
correlation_id="corr-run",
created_at=None, # Explicitly None
)
durable_message = DurableAgentStateMessage.from_run_request(run_request)
assert durable_message.created_at is None
def test_message_from_run_request_with_created_at_parses_correctly(self) -> None:
"""Test from_run_request correctly parses a valid created_at timestamp."""
run_request = RunRequest(
message="test message",
correlation_id="corr-run",
created_at="2024-01-15T10:30:00Z",
)
durable_message = DurableAgentStateMessage.from_run_request(run_request)
assert durable_message.created_at is not None
assert durable_message.created_at.year == 2024
assert durable_message.created_at.month == 1
assert durable_message.created_at.day == 15
if __name__ == "__main__":
@@ -1,115 +1,230 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for data models (RunRequest)."""
"""Unit tests for data models (AgentSessionId, RunRequest, AgentResponse)."""
import azure.durable_functions as df
import pytest
from agent_framework import Role
from pydantic import BaseModel
from agent_framework_durabletask._models import RunRequest
from agent_framework_azurefunctions._models import AgentSessionId, RunRequest
class ModuleStructuredResponse(BaseModel):
value: int
class TestAgentSessionId:
"""Test suite for AgentSessionId."""
def test_init_creates_session_id(self) -> None:
"""Test that AgentSessionId initializes correctly."""
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
assert session_id.name == "AgentEntity"
assert session_id.key == "test-key-123"
def test_with_random_key_generates_guid(self) -> None:
"""Test that with_random_key generates a GUID."""
session_id = AgentSessionId.with_random_key(name="AgentEntity")
assert session_id.name == "AgentEntity"
assert len(session_id.key) == 32 # UUID hex is 32 chars
# Verify it's a valid hex string
int(session_id.key, 16)
def test_with_random_key_unique_keys(self) -> None:
"""Test that with_random_key generates unique keys."""
session_id1 = AgentSessionId.with_random_key(name="AgentEntity")
session_id2 = AgentSessionId.with_random_key(name="AgentEntity")
assert session_id1.key != session_id2.key
def test_to_entity_id_conversion(self) -> None:
"""Test conversion to EntityId."""
session_id = AgentSessionId(name="AgentEntity", key="test-key")
entity_id = session_id.to_entity_id()
assert isinstance(entity_id, df.EntityId)
assert entity_id.name == "dafx-AgentEntity"
assert entity_id.key == "test-key"
def test_from_entity_id_conversion(self) -> None:
"""Test creation from EntityId."""
entity_id = df.EntityId(name="dafx-AgentEntity", key="test-key")
session_id = AgentSessionId.from_entity_id(entity_id)
assert isinstance(session_id, AgentSessionId)
assert session_id.name == "AgentEntity"
assert session_id.key == "test-key"
def test_round_trip_entity_id_conversion(self) -> None:
"""Test round-trip conversion to and from EntityId."""
original = AgentSessionId(name="AgentEntity", key="test-key")
entity_id = original.to_entity_id()
restored = AgentSessionId.from_entity_id(entity_id)
assert restored.name == original.name
assert restored.key == original.key
def test_str_representation(self) -> None:
"""Test string representation."""
session_id = AgentSessionId(name="AgentEntity", key="test-key-123")
str_repr = str(session_id)
assert str_repr == "@AgentEntity@test-key-123"
def test_repr_representation(self) -> None:
"""Test repr representation."""
session_id = AgentSessionId(name="AgentEntity", key="test-key")
repr_str = repr(session_id)
assert "AgentSessionId" in repr_str
assert "AgentEntity" in repr_str
assert "test-key" in repr_str
def test_parse_valid_session_id(self) -> None:
"""Test parsing valid session ID string."""
session_id = AgentSessionId.parse("@AgentEntity@test-key-123")
assert session_id.name == "AgentEntity"
assert session_id.key == "test-key-123"
def test_parse_invalid_format_no_prefix(self) -> None:
"""Test parsing invalid format without @ prefix."""
with pytest.raises(ValueError) as exc_info:
AgentSessionId.parse("AgentEntity@test-key")
assert "Invalid agent session ID format" in str(exc_info.value)
def test_parse_invalid_format_single_part(self) -> None:
"""Test parsing invalid format with single part."""
with pytest.raises(ValueError) as exc_info:
AgentSessionId.parse("@AgentEntity")
assert "Invalid agent session ID format" in str(exc_info.value)
def test_parse_with_multiple_at_signs_in_key(self) -> None:
"""Test parsing with @ signs in the key."""
session_id = AgentSessionId.parse("@AgentEntity@key-with@symbols")
assert session_id.name == "AgentEntity"
assert session_id.key == "key-with@symbols"
def test_parse_round_trip(self) -> None:
"""Test round-trip parse and string conversion."""
original = AgentSessionId(name="AgentEntity", key="test-key")
str_repr = str(original)
parsed = AgentSessionId.parse(str_repr)
assert parsed.name == original.name
assert parsed.key == original.key
def test_to_entity_name_adds_prefix(self) -> None:
"""Test that to_entity_name adds the dafx- prefix."""
entity_name = AgentSessionId.to_entity_name("TestAgent")
assert entity_name == "dafx-TestAgent"
def test_from_entity_id_strips_prefix(self) -> None:
"""Test that from_entity_id strips the dafx- prefix."""
entity_id = df.EntityId(name="dafx-TestAgent", key="key123")
session_id = AgentSessionId.from_entity_id(entity_id)
assert session_id.name == "TestAgent"
assert session_id.key == "key123"
def test_from_entity_id_raises_without_prefix(self) -> None:
"""Test that from_entity_id raises ValueError when entity name lacks the prefix."""
entity_id = df.EntityId(name="TestAgent", key="key123")
with pytest.raises(ValueError) as exc_info:
AgentSessionId.from_entity_id(entity_id)
assert "not a valid agent session ID" in str(exc_info.value)
assert "dafx-" in str(exc_info.value)
class TestRunRequest:
"""Test suite for RunRequest."""
def test_init_with_defaults(self) -> None:
"""Test RunRequest initialization with defaults."""
request = RunRequest(message="Hello", correlation_id="corr-001")
request = RunRequest(message="Hello", thread_id="thread-default")
assert request.message == "Hello"
assert request.correlation_id == "corr-001"
assert request.role == Role.USER
assert request.response_format is None
assert request.enable_tool_calls is True
assert request.wait_for_response is True
assert request.thread_id == "thread-default"
def test_init_with_all_fields(self) -> None:
"""Test RunRequest initialization with all fields."""
schema = ModuleStructuredResponse
request = RunRequest(
message="Hello",
correlation_id="corr-002",
thread_id="thread-123",
role=Role.SYSTEM,
response_format=schema,
enable_tool_calls=False,
wait_for_response=False,
)
assert request.message == "Hello"
assert request.correlation_id == "corr-002"
assert request.role == Role.SYSTEM
assert request.response_format is schema
assert request.enable_tool_calls is False
assert request.wait_for_response is False
assert request.thread_id == "thread-123"
def test_init_coerces_string_role(self) -> None:
"""Ensure string role values are coerced into Role instances."""
request = RunRequest(message="Hello", correlation_id="corr-003", role="system") # type: ignore[arg-type]
request = RunRequest(message="Hello", thread_id="thread-str-role", role="system") # type: ignore[arg-type]
assert request.role == Role.SYSTEM
def test_to_dict_with_defaults(self) -> None:
"""Test to_dict with default values."""
request = RunRequest(message="Test message", correlation_id="corr-004")
request = RunRequest(message="Test message", thread_id="thread-to-dict")
data = request.to_dict()
assert data["message"] == "Test message"
assert data["enable_tool_calls"] is True
assert data["wait_for_response"] is True
assert data["role"] == "user"
assert data["correlationId"] == "corr-004"
assert "response_format" not in data or data["response_format"] is None
assert "thread_id" not in data
assert data["thread_id"] == "thread-to-dict"
def test_to_dict_with_all_fields(self) -> None:
"""Test to_dict with all fields."""
schema = ModuleStructuredResponse
request = RunRequest(
message="Hello",
correlation_id="corr-005",
thread_id="thread-456",
role=Role.ASSISTANT,
response_format=schema,
enable_tool_calls=False,
wait_for_response=False,
)
data = request.to_dict()
assert data["message"] == "Hello"
assert data["correlationId"] == "corr-005"
assert data["role"] == "assistant"
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
assert data["response_format"]["module"] == schema.__module__
assert data["response_format"]["qualname"] == schema.__qualname__
assert data["enable_tool_calls"] is False
assert data["wait_for_response"] is False
assert "thread_id" not in data
assert data["thread_id"] == "thread-456"
def test_from_dict_with_defaults(self) -> None:
"""Test from_dict with minimal data."""
data = {"message": "Hello", "correlationId": "corr-006"}
data = {"message": "Hello", "thread_id": "thread-from-dict"}
request = RunRequest.from_dict(data)
assert request.message == "Hello"
assert request.correlation_id == "corr-006"
assert request.role == Role.USER
assert request.enable_tool_calls is True
assert request.wait_for_response is True
def test_from_dict_ignores_thread_id_field(self) -> None:
"""Ensure legacy thread_id input does not break RunRequest parsing."""
request = RunRequest.from_dict({"message": "Hello", "correlationId": "corr-007", "thread_id": "ignored"})
assert request.message == "Hello"
assert request.thread_id == "thread-from-dict"
def test_from_dict_with_all_fields(self) -> None:
"""Test from_dict with all fields."""
data = {
"message": "Test",
"correlationId": "corr-008",
"role": "system",
"response_format": {
"__response_schema_type__": "pydantic_model",
@@ -117,18 +232,19 @@ class TestRunRequest:
"qualname": ModuleStructuredResponse.__qualname__,
},
"enable_tool_calls": False,
"thread_id": "thread-789",
}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.correlation_id == "corr-008"
assert request.role == Role.SYSTEM
assert request.response_format is ModuleStructuredResponse
assert request.enable_tool_calls is False
assert request.thread_id == "thread-789"
def test_from_dict_unknown_role_preserves_value(self) -> None:
def test_from_dict_with_unknown_role_preserves_value(self) -> None:
"""Test from_dict keeps custom roles intact."""
data = {"message": "Test", "correlationId": "corr-009", "role": "reviewer"}
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
request = RunRequest.from_dict(data)
assert request.role.value == "reviewer"
@@ -136,22 +252,18 @@ class TestRunRequest:
def test_from_dict_empty_message(self) -> None:
"""Test from_dict with empty message."""
request = RunRequest.from_dict({"correlationId": "corr-010"})
data = {"thread_id": "thread-empty"}
request = RunRequest.from_dict(data)
assert request.message == ""
assert request.correlation_id == "corr-010"
assert request.role == Role.USER
def test_from_dict_missing_correlation_id_raises(self) -> None:
"""Test from_dict raises when correlationId is missing."""
with pytest.raises(ValueError, match="correlationId is required"):
RunRequest.from_dict({"message": "Test"})
assert request.thread_id == "thread-empty"
def test_round_trip_dict_conversion(self) -> None:
"""Test round-trip to_dict and from_dict."""
original = RunRequest(
message="Test message",
correlation_id="corr-011",
thread_id="thread-123",
role=Role.SYSTEM,
response_format=ModuleStructuredResponse,
enable_tool_calls=False,
@@ -161,16 +273,16 @@ class TestRunRequest:
restored = RunRequest.from_dict(data)
assert restored.message == original.message
assert restored.correlation_id == original.correlation_id
assert restored.role == original.role
assert restored.response_format is ModuleStructuredResponse
assert restored.enable_tool_calls == original.enable_tool_calls
assert restored.thread_id == original.thread_id
def test_round_trip_with_pydantic_response_format(self) -> None:
"""Ensure Pydantic response formats serialize and deserialize properly."""
original = RunRequest(
message="Structured",
correlation_id="corr-012",
thread_id="thread-pydantic",
response_format=ModuleStructuredResponse,
)
@@ -185,14 +297,14 @@ class TestRunRequest:
def test_init_with_correlationId(self) -> None:
"""Test RunRequest initialization with correlationId."""
request = RunRequest(message="Test message", correlation_id="corr-123")
request = RunRequest(message="Test message", thread_id="thread-corr-init", correlation_id="corr-123")
assert request.message == "Test message"
assert request.correlation_id == "corr-123"
def test_to_dict_with_correlationId(self) -> None:
"""Test to_dict includes correlationId."""
request = RunRequest(message="Test", correlation_id="corr-456")
request = RunRequest(message="Test", thread_id="thread-corr-to-dict", correlation_id="corr-456")
data = request.to_dict()
assert data["message"] == "Test"
@@ -200,18 +312,20 @@ class TestRunRequest:
def test_from_dict_with_correlationId(self) -> None:
"""Test from_dict with correlationId."""
data = {"message": "Test", "correlationId": "corr-789"}
data = {"message": "Test", "correlationId": "corr-789", "thread_id": "thread-corr-from-dict"}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.correlation_id == "corr-789"
assert request.thread_id == "thread-corr-from-dict"
def test_round_trip_with_correlationId(self) -> None:
"""Test round-trip to_dict and from_dict with correlationId."""
original = RunRequest(
message="Test message",
thread_id="thread-123",
role=Role.SYSTEM,
correlation_id="corr-124",
correlation_id="corr-123",
)
data = original.to_dict()
@@ -220,12 +334,13 @@ class TestRunRequest:
assert restored.message == original.message
assert restored.role == original.role
assert restored.correlation_id == original.correlation_id
assert restored.thread_id == original.thread_id
def test_init_with_orchestration_id(self) -> None:
"""Test RunRequest initialization with orchestration_id."""
request = RunRequest(
message="Test message",
correlation_id="corr-125",
thread_id="thread-orch-init",
orchestration_id="orch-123",
)
@@ -236,7 +351,7 @@ class TestRunRequest:
"""Test to_dict includes orchestrationId."""
request = RunRequest(
message="Test",
correlation_id="corr-126",
thread_id="thread-orch-to-dict",
orchestration_id="orch-456",
)
data = request.to_dict()
@@ -248,7 +363,7 @@ class TestRunRequest:
"""Test to_dict excludes orchestrationId when not set."""
request = RunRequest(
message="Test",
correlation_id="corr-127",
thread_id="thread-orch-none",
)
data = request.to_dict()
@@ -258,20 +373,22 @@ class TestRunRequest:
"""Test from_dict with orchestrationId."""
data = {
"message": "Test",
"correlationId": "corr-128",
"orchestrationId": "orch-789",
"thread_id": "thread-orch-from-dict",
}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.orchestration_id == "orch-789"
assert request.thread_id == "thread-orch-from-dict"
def test_round_trip_with_orchestration_id(self) -> None:
"""Test round-trip to_dict and from_dict with orchestration_id."""
original = RunRequest(
message="Test message",
thread_id="thread-123",
role=Role.SYSTEM,
correlation_id="corr-129",
correlation_id="corr-123",
orchestration_id="orch-123",
)
@@ -282,6 +399,20 @@ class TestRunRequest:
assert restored.role == original.role
assert restored.correlation_id == original.correlation_id
assert restored.orchestration_id == original.orchestration_id
assert restored.thread_id == original.thread_id
class TestModelIntegration:
"""Test suite for integration between models."""
def test_run_request_with_session_id(self) -> None:
"""Test using RunRequest with AgentSessionId."""
session_id = AgentSessionId.with_random_key("AgentEntity")
request = RunRequest(message="Test message", thread_id=str(session_id))
assert request.thread_id is not None
assert request.thread_id == str(session_id)
assert request.thread_id.startswith("@AgentEntity@")
if __name__ == "__main__":
@@ -6,11 +6,11 @@ from typing import Any
from unittest.mock import Mock
import pytest
from agent_framework import AgentRunResponse, ChatMessage, Role
from agent_framework_durabletask import DurableAIAgent
from agent_framework import AgentRunResponse, AgentThread, ChatMessage
from azure.durable_functions.models.Task import TaskBase, TaskState
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
from agent_framework_azurefunctions._orchestration import AgentTask
@@ -38,96 +38,46 @@ def _create_entity_task(task_id: int = 1) -> TaskBase:
return _FakeTask(task_id)
@pytest.fixture
def mock_context():
"""Create a mock orchestration context with UUID support."""
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
return context
@pytest.fixture
def mock_context_with_uuid() -> tuple[Mock, str]:
"""Create a mock context with a single UUID."""
from uuid import UUID
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
test_uuid = UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
context.new_uuid = Mock(return_value=test_uuid)
return context, test_uuid.hex
@pytest.fixture
def mock_context_with_multiple_uuids() -> tuple[Mock, list[str]]:
"""Create a mock context with multiple UUIDs via side_effect."""
from uuid import UUID
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
uuids = [
UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
UUID("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"),
UUID("cccccccc-cccc-cccc-cccc-cccccccccccc"),
]
context.new_uuid = Mock(side_effect=uuids)
# Return the hex versions for assertion checking
hex_uuids = [uuid.hex for uuid in uuids]
return context, hex_uuids
@pytest.fixture
def executor_with_uuid() -> tuple[Any, Mock, str]:
"""Create an executor with a mocked generate_unique_id method."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
executor = AzureFunctionsAgentExecutor(context)
test_uuid_hex = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
executor.generate_unique_id = Mock(return_value=test_uuid_hex)
return executor, context, test_uuid_hex
@pytest.fixture
def executor_with_multiple_uuids() -> tuple[Any, Mock, list[str]]:
"""Create an executor with multiple mocked UUIDs."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context = Mock()
context.instance_id = "test-instance"
context.current_utc_datetime = Mock()
executor = AzureFunctionsAgentExecutor(context)
uuid_hexes = [
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"cccccccc-cccc-cccc-cccc-cccccccccccc",
"dddddddd-dddd-dddd-dddd-dddddddddddd",
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
]
executor.generate_unique_id = Mock(side_effect=uuid_hexes)
return executor, context, uuid_hexes
@pytest.fixture
def executor_with_context(mock_context_with_uuid: tuple[Mock, str]) -> tuple[Any, Mock]:
"""Create an executor with a mocked context."""
from agent_framework_azurefunctions._orchestration import AzureFunctionsAgentExecutor
context, _ = mock_context_with_uuid
return AzureFunctionsAgentExecutor(context), context
class TestAgentResponseHelpers:
"""Tests for response handling through public AgentTask API."""
"""Tests for helper utilities that prepare AgentRunResponse values."""
@staticmethod
def _create_agent_task() -> AgentTask:
entity_task = _create_entity_task()
return AgentTask(entity_task, None, "correlation-id")
def test_load_agent_response_from_instance(self) -> None:
task = self._create_agent_task()
response = AgentRunResponse(messages=[ChatMessage(role="assistant", text='{"foo": "bar"}')])
loaded = task._load_agent_response(response)
assert loaded is response
assert loaded.value is None
def test_load_agent_response_from_serialized(self) -> None:
task = self._create_agent_task()
serialized = AgentRunResponse(messages=[ChatMessage(role="assistant", text="structured")]).to_dict()
serialized["value"] = {"answer": 42}
loaded = task._load_agent_response(serialized)
assert loaded is not None
assert loaded.value == {"answer": 42}
loaded_dict = loaded.to_dict()
assert loaded_dict["type"] == "agent_run_response"
def test_load_agent_response_rejects_none(self) -> None:
task = self._create_agent_task()
with pytest.raises(ValueError):
task._load_agent_response(None)
def test_load_agent_response_rejects_unsupported_type(self) -> None:
task = self._create_agent_task()
with pytest.raises(TypeError, match="Unsupported type"):
task._load_agent_response(["invalid", "list"]) # type: ignore[arg-type]
def test_try_set_value_success(self) -> None:
"""Test try_set_value correctly processes successful task completion."""
@@ -194,10 +144,336 @@ class TestAgentResponseHelpers:
assert isinstance(task.result.value, TestSchema)
assert task.result.value.answer == "42"
def test_ensure_response_format_parses_value(self) -> None:
"""Test _ensure_response_format correctly parses response value."""
from pydantic import BaseModel
class SampleSchema(BaseModel):
name: str
task = self._create_agent_task()
response = AgentRunResponse(messages=[ChatMessage(role="assistant", text='{"name": "test"}')])
# Value should be None initially
assert response.value is None
# Parse the value
task._ensure_response_format(SampleSchema, "test-correlation", response)
# Value should now be parsed
assert isinstance(response.value, SampleSchema)
assert response.value.name == "test"
def test_ensure_response_format_skips_if_already_parsed(self) -> None:
"""Test _ensure_response_format does not re-parse if value already matches format."""
from pydantic import BaseModel
class SampleSchema(BaseModel):
name: str
task = self._create_agent_task()
existing_value = SampleSchema(name="existing")
response = AgentRunResponse(
messages=[ChatMessage(role="assistant", text='{"name": "new"}')],
value=existing_value,
)
# Call _ensure_response_format
task._ensure_response_format(SampleSchema, "test-correlation", response)
# Value should remain unchanged (not re-parsed)
assert response.value is existing_value
assert response.value.name == "existing"
class TestDurableAIAgent:
"""Test suite for DurableAIAgent wrapper."""
def test_init(self) -> None:
"""Test DurableAIAgent initialization."""
mock_context = Mock()
mock_context.instance_id = "test-instance-123"
agent = DurableAIAgent(mock_context, "TestAgent")
assert agent.context == mock_context
assert agent.agent_name == "TestAgent"
def test_implements_agent_protocol(self) -> None:
"""Test that DurableAIAgent implements AgentProtocol."""
from agent_framework import AgentProtocol
mock_context = Mock()
agent = DurableAIAgent(mock_context, "TestAgent")
# Check that agent satisfies AgentProtocol
assert isinstance(agent, AgentProtocol)
def test_has_agent_protocol_properties(self) -> None:
"""Test that DurableAIAgent has AgentProtocol properties."""
mock_context = Mock()
agent = DurableAIAgent(mock_context, "TestAgent")
# AgentProtocol properties
assert hasattr(agent, "id")
assert hasattr(agent, "name")
assert hasattr(agent, "description")
assert hasattr(agent, "display_name")
# Verify values
assert agent.name == "TestAgent"
assert agent.description == "Durable agent proxy for TestAgent"
assert agent.display_name == "TestAgent"
assert agent.id is not None # Auto-generated UUID
def test_get_new_thread(self) -> None:
"""Test creating a new agent thread."""
mock_context = Mock()
mock_context.instance_id = "test-instance-456"
mock_context.new_uuid = Mock(return_value="test-guid-456")
agent = DurableAIAgent(mock_context, "WriterAgent")
thread = agent.get_new_thread()
assert isinstance(thread, DurableAgentThread)
assert thread.session_id is not None
session_id = thread.session_id
assert isinstance(session_id, AgentSessionId)
assert session_id.name == "WriterAgent"
assert session_id.key == "test-guid-456"
mock_context.new_uuid.assert_called_once()
def test_get_new_thread_deterministic(self) -> None:
"""Test that get_new_thread creates deterministic session IDs."""
mock_context = Mock()
mock_context.instance_id = "test-instance-789"
mock_context.new_uuid = Mock(side_effect=["session-guid-1", "session-guid-2"])
agent = DurableAIAgent(mock_context, "EditorAgent")
# Create multiple threads - they should have unique session IDs
thread1 = agent.get_new_thread()
thread2 = agent.get_new_thread()
assert isinstance(thread1, DurableAgentThread)
assert isinstance(thread2, DurableAgentThread)
session_id1 = thread1.session_id
session_id2 = thread2.session_id
assert session_id1 is not None and session_id2 is not None
assert isinstance(session_id1, AgentSessionId)
assert isinstance(session_id2, AgentSessionId)
assert session_id1.name == "EditorAgent"
assert session_id2.name == "EditorAgent"
assert session_id1.key == "session-guid-1"
assert session_id2.key == "session-guid-2"
assert mock_context.new_uuid.call_count == 2
def test_run_creates_entity_call(self) -> None:
"""Test that run() creates proper entity call and returns a Task."""
mock_context = Mock()
mock_context.instance_id = "test-instance-001"
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
entity_task = _create_entity_task()
mock_context.call_entity = Mock(return_value=entity_task)
agent = DurableAIAgent(mock_context, "TestAgent")
# Create thread
thread = agent.get_new_thread()
# Call run() - returns AgentTask directly
task = agent.run(messages="Test message", thread=thread, enable_tool_calls=True)
assert isinstance(task, AgentTask)
assert task.children[0] == entity_task
# Verify call_entity was called with correct parameters
assert mock_context.call_entity.called
call_args = mock_context.call_entity.call_args
entity_id, operation, request = call_args[0]
assert operation == "run"
assert request["message"] == "Test message"
assert request["enable_tool_calls"] is True
assert "correlationId" in request
assert request["correlationId"] == "correlation-guid"
assert "thread_id" in request
assert request["thread_id"] == "thread-guid"
# Verify orchestration ID is set from context.instance_id
assert "orchestrationId" in request
assert request["orchestrationId"] == "test-instance-001"
def test_run_sets_orchestration_id(self) -> None:
"""Test that run() sets the orchestration_id from context.instance_id."""
mock_context = Mock()
mock_context.instance_id = "my-orchestration-123"
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
entity_task = _create_entity_task()
mock_context.call_entity = Mock(return_value=entity_task)
agent = DurableAIAgent(mock_context, "TestAgent")
thread = agent.get_new_thread()
agent.run(messages="Test", thread=thread)
call_args = mock_context.call_entity.call_args
request = call_args[0][2]
assert request["orchestrationId"] == "my-orchestration-123"
def test_run_without_thread(self) -> None:
"""Test that run() works without explicit thread (creates unique session key)."""
mock_context = Mock()
mock_context.instance_id = "test-instance-002"
mock_context.new_uuid = Mock(side_effect=["auto-generated-guid", "correlation-guid"])
entity_task = _create_entity_task()
mock_context.call_entity = Mock(return_value=entity_task)
agent = DurableAIAgent(mock_context, "TestAgent")
# Call without thread
task = agent.run(messages="Test message")
assert isinstance(task, AgentTask)
assert task.children[0] == entity_task
# Verify the entity ID uses the auto-generated GUID with dafx- prefix
call_args = mock_context.call_entity.call_args
entity_id = call_args[0][0]
assert entity_id.name == "dafx-TestAgent"
assert entity_id.key == "auto-generated-guid"
# Should be called twice: once for session_key, once for correlationId
assert mock_context.new_uuid.call_count == 2
def test_run_with_response_format(self) -> None:
"""Test that run() passes response format correctly."""
mock_context = Mock()
mock_context.instance_id = "test-instance-003"
entity_task = _create_entity_task()
mock_context.call_entity = Mock(return_value=entity_task)
agent = DurableAIAgent(mock_context, "TestAgent")
from pydantic import BaseModel
class SampleSchema(BaseModel):
key: str
# Create thread and call
thread = agent.get_new_thread()
task = agent.run(messages="Test message", thread=thread, response_format=SampleSchema)
assert isinstance(task, AgentTask)
assert task.children[0] == entity_task
# Verify schema was passed in the call_entity arguments
call_args = mock_context.call_entity.call_args
input_data = call_args[0][2] # Third argument is input_data
assert "response_format" in input_data
assert input_data["response_format"]["__response_schema_type__"] == "pydantic_model"
assert input_data["response_format"]["module"] == SampleSchema.__module__
assert input_data["response_format"]["qualname"] == SampleSchema.__qualname__
def test_messages_to_string(self) -> None:
"""Test converting ChatMessage list to string."""
from agent_framework import ChatMessage
mock_context = Mock()
agent = DurableAIAgent(mock_context, "TestAgent")
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(role="assistant", text="Hi there"),
ChatMessage(role="user", text="How are you?"),
]
result = agent._messages_to_string(messages)
assert result == "Hello\nHi there\nHow are you?"
def test_run_with_chat_message(self) -> None:
"""Test that run() handles ChatMessage input."""
from agent_framework import ChatMessage
mock_context = Mock()
mock_context.new_uuid = Mock(side_effect=["thread-guid", "correlation-guid"])
entity_task = _create_entity_task()
mock_context.call_entity = Mock(return_value=entity_task)
agent = DurableAIAgent(mock_context, "TestAgent")
thread = agent.get_new_thread()
# Call with ChatMessage
msg = ChatMessage(role="user", text="Hello")
task = agent.run(messages=msg, thread=thread)
assert isinstance(task, AgentTask)
assert task.children[0] == entity_task
# Verify message was converted to string
call_args = mock_context.call_entity.call_args
request = call_args[0][2]
assert request["message"] == "Hello"
def test_run_stream_raises_not_implemented(self) -> None:
"""Test that run_stream() method raises NotImplementedError."""
mock_context = Mock()
agent = DurableAIAgent(mock_context, "TestAgent")
with pytest.raises(NotImplementedError) as exc_info:
agent.run_stream("Test message")
error_msg = str(exc_info.value)
assert "Streaming is not supported" in error_msg
def test_entity_id_format(self) -> None:
"""Test that EntityId is created with correct format (name, key)."""
from azure.durable_functions import EntityId
mock_context = Mock()
mock_context.new_uuid = Mock(return_value="test-guid-789")
mock_context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(mock_context, "WriterAgent")
thread = agent.get_new_thread()
# Call run() to trigger entity ID creation
agent.run("Test", thread=thread)
# Verify call_entity was called with correct EntityId
call_args = mock_context.call_entity.call_args
entity_id = call_args[0][0]
# EntityId should be EntityId(name="dafx-WriterAgent", key="test-guid-789")
# Which formats as "@dafx-writeragent@test-guid-789"
assert isinstance(entity_id, EntityId)
assert entity_id.name == "dafx-WriterAgent"
assert entity_id.key == "test-guid-789"
assert str(entity_id) == "@dafx-writeragent@test-guid-789"
class TestAgentFunctionAppGetAgent:
"""Test suite for AgentFunctionApp.get_agent."""
def test_get_agent_method(self) -> None:
"""Test get_agent method creates DurableAIAgent for registered agent."""
app = _app_with_registered_agents("MyAgent")
mock_context = Mock()
mock_context.instance_id = "test-instance-100"
agent = app.get_agent(mock_context, "MyAgent")
assert isinstance(agent, DurableAIAgent)
assert agent.agent_name == "MyAgent"
assert agent.context == mock_context
def test_get_agent_raises_for_unregistered_agent(self) -> None:
"""Test get_agent raises ValueError when agent is not registered."""
app = _app_with_registered_agents("KnownAgent")
@@ -206,87 +482,18 @@ class TestAgentFunctionAppGetAgent:
app.get_agent(Mock(), "MissingAgent")
class TestAzureFunctionsFireAndForget:
"""Test fire-and-forget mode for AzureFunctionsAgentExecutor."""
def test_fire_and_forget_calls_signal_entity(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False calls signal_entity instead of call_entity."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
# Run with wait_for_response=False
result = agent.run("Test message", thread=thread, wait_for_response=False)
# Verify signal_entity was called and call_entity was not
assert context.signal_entity.call_count == 1
assert context.call_entity.call_count == 0
# Should still return an AgentTask
assert isinstance(result, AgentTask)
def test_fire_and_forget_returns_completed_task(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False returns pre-completed AgentTask."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
result = agent.run("Test message", thread=thread, wait_for_response=False)
# Task should be immediately complete
assert isinstance(result, AgentTask)
assert result.is_completed
def test_fire_and_forget_returns_acceptance_response(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=False returns acceptance response."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
result = agent.run("Test message", thread=thread, wait_for_response=False)
# Get the result
response = result.result
assert isinstance(response, AgentRunResponse)
assert len(response.messages) == 1
assert response.messages[0].role == Role.SYSTEM
# Check message contains key information
message_text = response.messages[0].text
assert "accepted" in message_text.lower()
assert "background" in message_text.lower()
def test_blocking_mode_still_works(self, executor_with_uuid: tuple[Any, Mock, str]) -> None:
"""Verify wait_for_response=True uses call_entity as before."""
executor, context, _ = executor_with_uuid
context.signal_entity = Mock()
context.call_entity = Mock(return_value=_create_entity_task())
agent = DurableAIAgent(executor, "TestAgent")
thread = agent.get_new_thread()
result = agent.run("Test message", thread=thread, wait_for_response=True)
# Verify call_entity was called and signal_entity was not
assert context.call_entity.call_count == 1
assert context.signal_entity.call_count == 0
# Should return an AgentTask
assert isinstance(result, AgentTask)
class TestOrchestrationIntegration:
"""Integration tests for orchestration scenarios."""
def test_sequential_agent_calls_simulation(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
def test_sequential_agent_calls_simulation(self) -> None:
"""Simulate sequential agent calls in an orchestration."""
executor, context, uuid_hexes = executor_with_multiple_uuids
mock_context = Mock()
mock_context.instance_id = "test-orchestration-001"
# new_uuid will be called 3 times:
# 1. thread creation
# 2. correlationId for first call
# 3. correlationId for second call
mock_context.new_uuid = Mock(side_effect=["deterministic-guid-001", "corr-1", "corr-2"])
# Track entity calls
entity_calls: list[dict[str, Any]] = []
@@ -295,10 +502,10 @@ class TestOrchestrationIntegration:
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
return _create_entity_task()
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create agent directly with executor (not via app.get_agent)
agent = DurableAIAgent(executor, "WriterAgent")
app = _app_with_registered_agents("WriterAgent")
agent = app.get_agent(mock_context, "WriterAgent")
# Create thread
thread = agent.get_new_thread()
@@ -314,15 +521,18 @@ class TestOrchestrationIntegration:
# Verify both calls used the same entity (same session key)
assert len(entity_calls) == 2
assert entity_calls[0]["entity_id"] == entity_calls[1]["entity_id"]
# EntityId format is @dafx-writeragent@<uuid_hex>
expected_entity_id = f"@dafx-writeragent@{uuid_hexes[0]}"
assert entity_calls[0]["entity_id"] == expected_entity_id
# generate_unique_id called 3 times: thread + 2 correlation IDs
assert executor.generate_unique_id.call_count == 3
# EntityId format is @dafx-writeragent@deterministic-guid-001
assert entity_calls[0]["entity_id"] == "@dafx-writeragent@deterministic-guid-001"
# new_uuid called 3 times: thread + 2 correlation IDs
assert mock_context.new_uuid.call_count == 3
def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
def test_multiple_agents_in_orchestration(self) -> None:
"""Test using multiple different agents in one orchestration."""
executor, context, uuid_hexes = executor_with_multiple_uuids
mock_context = Mock()
mock_context.instance_id = "test-orchestration-002"
# Mock new_uuid to return different GUIDs for each call
# Order: writer thread, editor thread, writer correlation, editor correlation
mock_context.new_uuid = Mock(side_effect=["writer-guid-001", "editor-guid-002", "writer-corr", "editor-corr"])
entity_calls: list[str] = []
@@ -330,11 +540,11 @@ class TestOrchestrationIntegration:
entity_calls.append(str(entity_id))
return _create_entity_task()
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
# Create agents directly with executor (not via app.get_agent)
writer = DurableAIAgent(executor, "WriterAgent")
editor = DurableAIAgent(executor, "EditorAgent")
app = _app_with_registered_agents("WriterAgent", "EditorAgent")
writer = app.get_agent(mock_context, "WriterAgent")
editor = app.get_agent(mock_context, "EditorAgent")
writer_thread = writer.get_new_thread()
editor_thread = editor.get_new_thread()
@@ -348,11 +558,62 @@ class TestOrchestrationIntegration:
# Verify different entity IDs were used
assert len(entity_calls) == 2
# EntityId format is @dafx-agentname@uuid_hex (lowercased agent name with dafx- prefix)
expected_writer_id = f"@dafx-writeragent@{uuid_hexes[0]}"
expected_editor_id = f"@dafx-editoragent@{uuid_hexes[1]}"
assert entity_calls[0] == expected_writer_id
assert entity_calls[1] == expected_editor_id
# EntityId format is @dafx-agentname@guid (lowercased agent name with dafx- prefix)
assert entity_calls[0] == "@dafx-writeragent@writer-guid-001"
assert entity_calls[1] == "@dafx-editoragent@editor-guid-002"
class TestAgentThreadSerialization:
"""Test that AgentThread can be serialized for orchestration state."""
async def test_agent_thread_serialize(self) -> None:
"""Test that AgentThread can be serialized."""
thread = AgentThread()
# Serialize
serialized = await thread.serialize()
assert isinstance(serialized, dict)
assert "service_thread_id" in serialized
async def test_agent_thread_deserialize(self) -> None:
"""Test that AgentThread can be deserialized."""
thread = AgentThread()
serialized = await thread.serialize()
# Deserialize
restored = await AgentThread.deserialize(serialized)
assert isinstance(restored, AgentThread)
assert restored.service_thread_id == thread.service_thread_id
async def test_durable_agent_thread_serialization(self) -> None:
"""Test that DurableAgentThread persists session metadata during serialization."""
mock_context = Mock()
mock_context.instance_id = "test-instance-999"
mock_context.new_uuid = Mock(return_value="test-guid-999")
agent = DurableAIAgent(mock_context, "TestAgent")
thread = agent.get_new_thread()
assert isinstance(thread, DurableAgentThread)
# Verify custom attribute and property exist
assert thread.session_id is not None
session_id = thread.session_id
assert isinstance(session_id, AgentSessionId)
assert session_id.name == "TestAgent"
assert session_id.key == "test-guid-999"
# Standard serialization should still work
serialized = await thread.serialize()
assert isinstance(serialized, dict)
assert serialized.get("durable_session_id") == str(session_id)
# After deserialization, we'd need to restore the custom attribute
# This would be handled by the orchestration framework
restored = await DurableAgentThread.deserialize(serialized)
assert isinstance(restored, DurableAgentThread)
assert restored.session_id == session_id
if __name__ == "__main__":
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251120"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+6 -43
View File
@@ -16,7 +16,6 @@ from typing import (
Generic,
Literal,
Protocol,
TypedDict,
TypeVar,
cast,
get_args,
@@ -74,7 +73,6 @@ __all__ = [
"FunctionInvocationConfiguration",
"HostedCodeInterpreterTool",
"HostedFileSearchTool",
"HostedImageGenerationTool",
"HostedMCPSpecificApproval",
"HostedMCPTool",
"HostedWebSearchTool",
@@ -326,41 +324,6 @@ class HostedWebSearchTool(BaseTool):
super().__init__(**args)
class HostedImageGenerationToolOptions(TypedDict, total=False):
"""Options for HostedImageGenerationTool."""
count: int
image_size: str
media_type: str
model_id: str
response_format: Literal["uri", "data", "hosted"]
streaming_count: int
class HostedImageGenerationTool(BaseTool):
"""Represents a hosted tool that can be specified to an AI service to enable it to perform image generation."""
def __init__(
self,
*,
options: HostedImageGenerationToolOptions | None = None,
description: str | None = None,
additional_properties: dict[str, Any] | None = None,
**kwargs: Any,
):
"""Initialize a HostedImageGenerationTool."""
if "name" in kwargs:
raise ValueError("The 'name' argument is reserved for the HostedImageGenerationTool and cannot be set.")
self.options = options
super().__init__(
name="image_generation",
description=description or "",
additional_properties=additional_properties,
**kwargs,
)
class HostedMCPSpecificApproval(TypedDict, total=False):
"""Represents the specific mode for a hosted tool.
@@ -1456,11 +1419,14 @@ async def _auto_invoke_function(
Raises:
KeyError: If the requested function is not found in the tool map.
"""
from ._types import (
FunctionResultContent,
)
# Note: The scenarios for approval_mode="always_require", declaration_only, and
# terminate_on_unknown_calls are all handled in _try_execute_function_calls before
# this function is called. This function only handles the actual execution of approved,
# non-declaration-only functions.
from ._types import FunctionCallContent, FunctionResultContent
tool: AIFunction[BaseModel, Any] | None = None
if function_call_content.type == "function_call":
@@ -1478,14 +1444,11 @@ async def _auto_invoke_function(
else:
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
# and never reach this function, so we only handle approved=True cases here.
inner_call = function_call_content.function_call
if not isinstance(inner_call, FunctionCallContent):
return function_call_content
tool = tool_map.get(inner_call.name)
tool = tool_map.get(function_call_content.function_call.name)
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = inner_call
function_call_content = function_call_content.function_call
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
+2 -272
View File
@@ -40,8 +40,6 @@ __all__ = [
"ChatResponse",
"ChatResponseUpdate",
"CitationAnnotation",
"CodeInterpreterToolCallContent",
"CodeInterpreterToolResultContent",
"Contents",
"DataContent",
"ErrorContent",
@@ -52,10 +50,6 @@ __all__ = [
"FunctionResultContent",
"HostedFileContent",
"HostedVectorStoreContent",
"ImageGenerationToolCallContent",
"ImageGenerationToolResultContent",
"MCPServerToolCallContent",
"MCPServerToolResultContent",
"Role",
"TextContent",
"TextReasoningContent",
@@ -127,18 +121,6 @@ def _parse_content(content_data: MutableMapping[str, Any]) -> "Contents":
return HostedFileContent.from_dict(content_data)
case "hosted_vector_store":
return HostedVectorStoreContent.from_dict(content_data)
case "code_interpreter_tool_call":
return CodeInterpreterToolCallContent.from_dict(content_data)
case "code_interpreter_tool_result":
return CodeInterpreterToolResultContent.from_dict(content_data)
case "image_generation_tool_call":
return ImageGenerationToolCallContent.from_dict(content_data)
case "image_generation_tool_result":
return ImageGenerationToolResultContent.from_dict(content_data)
case "mcp_server_tool_call":
return MCPServerToolCallContent.from_dict(content_data)
case "mcp_server_tool_result":
return MCPServerToolResultContent.from_dict(content_data)
case "function_approval_request":
return FunctionApprovalRequestContent.from_dict(content_data)
case "function_approval_response":
@@ -1625,8 +1607,6 @@ class HostedFileContent(BaseContent):
self,
file_id: str,
*,
media_type: str | None = None,
name: str | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
@@ -1635,8 +1615,6 @@ class HostedFileContent(BaseContent):
Args:
file_id: The identifier of the hosted file.
media_type: Optional media type of the hosted file.
name: Optional display name of the hosted file.
Keyword Args:
additional_properties: Optional additional properties associated with the content.
@@ -1649,14 +1627,8 @@ class HostedFileContent(BaseContent):
**kwargs,
)
self.file_id = file_id
self.media_type = media_type
self.name = name
self.type: Literal["hosted_file"] = "hosted_file"
def has_top_level_media_type(self, top_level_media_type: Literal["application", "audio", "image", "text"]) -> bool:
"""Returns a boolean indicating if the media type has the specified top-level media type."""
return _has_top_level_media_type(self.media_type, top_level_media_type)
class HostedVectorStoreContent(BaseContent):
"""Represents a hosted vector store content.
@@ -1704,234 +1676,6 @@ class HostedVectorStoreContent(BaseContent):
self.type: Literal["hosted_vector_store"] = "hosted_vector_store"
class CodeInterpreterToolCallContent(BaseContent):
"""Represents a code interpreter tool call invocation by a hosted service."""
def __init__(
self,
*,
call_id: str | None = None,
inputs: Sequence["Contents | MutableMapping[str, Any]"] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.call_id = call_id
self.inputs: list["Contents"] | None = None
if inputs:
normalized_inputs: Sequence["Contents | MutableMapping[str, Any]"] = (
inputs
if isinstance(inputs, Sequence) and not isinstance(inputs, (str, bytes, MutableMapping))
else [inputs]
)
self.inputs = _parse_content_list(list(normalized_inputs))
self.type: Literal["code_interpreter_tool_call"] = "code_interpreter_tool_call"
class CodeInterpreterToolResultContent(BaseContent):
"""Represents the result of a code interpreter tool invocation by a hosted service."""
def __init__(
self,
*,
call_id: str | None = None,
outputs: Sequence["Contents | MutableMapping[str, Any]"] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.call_id = call_id
self.outputs: list["Contents"] | None = None
if outputs:
normalized_outputs: Sequence["Contents | MutableMapping[str, Any]"] = (
outputs
if isinstance(outputs, Sequence) and not isinstance(outputs, (str, bytes, MutableMapping))
else [outputs]
)
self.outputs = _parse_content_list(list(normalized_outputs))
self.type: Literal["code_interpreter_tool_result"] = "code_interpreter_tool_result"
class ImageGenerationToolCallContent(BaseContent):
"""Represents the invocation of an image generation tool call by a hosted service."""
def __init__(
self,
*,
image_id: str | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes an ImageGenerationToolCallContent instance.
Keyword Args:
image_id: The identifier of the image to be generated.
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
**kwargs: Any additional keyword arguments.
"""
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.image_id = image_id
self.type: Literal["image_generation_tool_call"] = "image_generation_tool_call"
class ImageGenerationToolResultContent(BaseContent):
"""Represents the result of an image generation tool call invocation by a hosted service."""
def __init__(
self,
*,
image_id: str | None = None,
outputs: DataContent | UriContent | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes an ImageGenerationToolResultContent instance.
Keyword Args:
image_id: The identifier of the generated image.
outputs: The outputs of the image generation tool call.
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
**kwargs: Any additional keyword arguments.
"""
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.image_id = image_id
self.outputs: DataContent | UriContent | None = outputs
self.type: Literal["image_generation_tool_result"] = "image_generation_tool_result"
class MCPServerToolCallContent(BaseContent):
"""Represents a tool call request to a MCP server."""
def __init__(
self,
call_id: str,
tool_name: str,
server_name: str | None = None,
*,
arguments: str | Mapping[str, Any] | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes a MCPServerToolCallContent instance.
Args:
call_id: The tool call identifier.
tool_name: The name of the tool requested.
server_name: The name of the MCP server where the tool is hosted.
Keyword Args:
arguments: The arguments requested to be provided to the tool,
can be a string to allow gradual completion of the args.
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
**kwargs: Any additional keyword arguments.
"""
if not call_id:
raise ValueError("call_id must be a non-empty string.")
if not tool_name:
raise ValueError("tool_name must be a non-empty string.")
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.call_id = call_id
self.tool_name = tool_name
self.name = tool_name
self.server_name = server_name
self.arguments = arguments
self.type: Literal["mcp_server_tool_call"] = "mcp_server_tool_call"
def parse_arguments(self) -> dict[str, Any] | None:
"""Returns the parsed arguments for the MCP server tool call, if any."""
if isinstance(self.arguments, str):
# If arguments are a string, try to parse it as JSON
try:
loaded = json.loads(self.arguments)
if isinstance(loaded, dict):
return loaded # type:ignore
return {"raw": loaded}
except (json.JSONDecodeError, TypeError):
return {"raw": self.arguments}
return cast(dict[str, Any] | None, self.arguments)
class MCPServerToolResultContent(BaseContent):
"""Represents the result of a MCP server tool call."""
def __init__(
self,
call_id: str,
*,
output: Any | None = None,
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
**kwargs: Any,
) -> None:
"""Initializes a MCPServerToolResultContent instance.
Args:
call_id: The identifier of the tool call for which this is the result.
Keyword Args:
output: The output of the MCP server tool call.
annotations: Optional annotations associated with the content.
additional_properties: Optional additional properties associated with the content.
raw_representation: Optional raw representation of the content.
**kwargs: Any additional keyword arguments.
"""
if not call_id:
raise ValueError("call_id must be a non-empty string.")
super().__init__(
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
**kwargs,
)
self.call_id = call_id
self.output: Any | None = output
self.type: Literal["mcp_server_tool_result"] = "mcp_server_tool_result"
class BaseUserInputRequest(BaseContent):
"""Base class for all user requests."""
@@ -1992,7 +1736,7 @@ class FunctionApprovalResponseContent(BaseContent):
approved: bool,
*,
id: str,
function_call: FunctionCallContent | MCPServerToolCallContent | MutableMapping[str, Any],
function_call: FunctionCallContent | MutableMapping[str, Any],
annotations: Sequence[Annotations | MutableMapping[str, Any]] | None = None,
additional_properties: dict[str, Any] | None = None,
raw_representation: Any | None = None,
@@ -2020,12 +1764,8 @@ class FunctionApprovalResponseContent(BaseContent):
self.id = id
self.approved = approved
# Convert dict to FunctionCallContent if needed (for SerializationMixin support)
self.function_call: FunctionCallContent | MCPServerToolCallContent
if isinstance(function_call, MutableMapping):
if function_call.get("type") == "mcp_server_tool_call":
self.function_call = MCPServerToolCallContent.from_dict(function_call)
else:
self.function_call = FunctionCallContent.from_dict(function_call)
self.function_call = FunctionCallContent.from_dict(function_call)
else:
self.function_call = function_call
# Override the type for this specific subclass
@@ -2083,7 +1823,6 @@ class FunctionApprovalRequestContent(BaseContent):
**kwargs,
)
self.id = id
self.function_call: FunctionCallContent
# Convert dict to FunctionCallContent if needed (for SerializationMixin support)
if isinstance(function_call, MutableMapping):
self.function_call = FunctionCallContent.from_dict(function_call)
@@ -2115,12 +1854,6 @@ Contents = (
| UsageContent
| HostedFileContent
| HostedVectorStoreContent
| CodeInterpreterToolCallContent
| CodeInterpreterToolResultContent
| ImageGenerationToolCallContent
| ImageGenerationToolResultContent
| MCPServerToolCallContent
| MCPServerToolResultContent
| FunctionApprovalRequestContent
| FunctionApprovalResponseContent
)
@@ -2136,9 +1869,6 @@ def _prepare_function_call_results_as_dumpable(content: Contents | Any | list[Co
return content.model_dump()
if hasattr(content, "to_dict"):
return content.to_dict(exclude={"raw_representation", "additional_properties"})
# Handle objects with text attribute (e.g., MCP TextContent)
if hasattr(content, "text") and isinstance(content.text, str):
return content.text
return content
@@ -309,9 +309,6 @@ class WorkflowAgent(BaseAgent):
if isinstance(executor, AgentExecutor) and not executor.output_response:
return None
if update:
# Enrich with executor identity if author_name is not already set
if not update.author_name:
update.author_name = executor_id
return update
return None
@@ -3,7 +3,7 @@
import json
import sys
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence
from typing import Any, cast
from typing import Any
from openai import AsyncOpenAI
from openai.types.beta.threads import (
@@ -28,11 +28,9 @@ from .._types import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
Contents,
FunctionCallContent,
FunctionResultContent,
MCPServerToolCallContent,
Role,
TextContent,
ToolMode,
@@ -379,37 +377,10 @@ class OpenAIAssistantsClient(OpenAIConfigMixin, BaseChatClient):
if event_data.required_action is not None:
for tool_call in event_data.required_action.submit_tool_outputs.tool_calls:
tool_call_any = cast(Any, tool_call)
call_id = json.dumps([response_id, tool_call.id])
tool_type = getattr(tool_call, "type", None)
if tool_type == "code_interpreter" and getattr(tool_call_any, "code_interpreter", None):
code_input = getattr(tool_call_any.code_interpreter, "input", None)
inputs = (
[TextContent(text=code_input, raw_representation=tool_call)] if code_input is not None else None
)
contents.append(
CodeInterpreterToolCallContent(
call_id=call_id,
inputs=inputs,
raw_representation=tool_call,
)
)
elif tool_type == "mcp":
contents.append(
MCPServerToolCallContent(
call_id=call_id,
tool_name=getattr(tool_call, "name", "") or "",
server_name=getattr(tool_call, "server_label", None),
arguments=getattr(tool_call, "args", None),
raw_representation=tool_call,
)
)
else:
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(
FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments)
)
function_name = tool_call.function.name
function_arguments = json.loads(tool_call.function.arguments)
contents.append(FunctionCallContent(call_id=call_id, name=function_name, arguments=function_arguments))
return contents
@@ -1,14 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import (
AsyncIterable,
Awaitable,
Callable,
Mapping,
MutableMapping,
MutableSequence,
Sequence,
)
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, MutableSequence, Sequence
from datetime import datetime, timezone
from itertools import chain
from typing import Any, TypeVar, cast
@@ -20,9 +12,7 @@ from openai.types.responses.parsed_response import (
ParsedResponse,
)
from openai.types.responses.response import Response as OpenAIResponse
from openai.types.responses.response_stream_event import (
ResponseStreamEvent as OpenAIResponseStreamEvent,
)
from openai.types.responses.response_stream_event import ResponseStreamEvent as OpenAIResponseStreamEvent
from openai.types.responses.response_usage import ResponseUsage
from openai.types.responses.tool_param import (
CodeInterpreter,
@@ -30,9 +20,7 @@ from openai.types.responses.tool_param import (
Mcp,
ToolParam,
)
from openai.types.responses.web_search_tool_param import (
UserLocation as WebSearchUserLocation,
)
from openai.types.responses.web_search_tool_param import UserLocation as WebSearchUserLocation
from openai.types.responses.web_search_tool_param import WebSearchToolParam
from pydantic import BaseModel, ValidationError
@@ -43,7 +31,6 @@ from .._tools import (
AIFunction,
HostedCodeInterpreterTool,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedWebSearchTool,
ToolProtocol,
@@ -55,8 +42,6 @@ from .._types import (
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
Contents,
DataContent,
FunctionApprovalRequestContent,
@@ -65,10 +50,6 @@ from .._types import (
FunctionResultContent,
HostedFileContent,
HostedVectorStoreContent,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
MCPServerToolCallContent,
MCPServerToolResultContent,
Role,
TextContent,
TextReasoningContent,
@@ -76,7 +57,6 @@ from .._types import (
UriContent,
UsageContent,
UsageDetails,
_parse_content,
prepare_function_call_results,
)
from ..exceptions import (
@@ -151,17 +131,13 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
if "text_format" not in run_options:
async for chunk in await client.responses.create(stream=True, **run_options):
yield self._parse_chunk_from_openai(
chunk,
chat_options=chat_options,
function_call_ids=function_call_ids,
chunk, chat_options=chat_options, function_call_ids=function_call_ids
)
return
async with client.responses.stream(**run_options) as response:
async for chunk in response:
yield self._parse_chunk_from_openai(
chunk,
chat_options=chat_options,
function_call_ids=function_call_ids,
chunk, chat_options=chat_options, function_call_ids=function_call_ids
)
except BadRequestError as ex:
if ex.code == "content_filter":
@@ -338,28 +314,39 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
else None,
)
)
case HostedImageGenerationTool():
mapped_tool: dict[str, Any] = {"type": "image_generation"}
if tool.options:
option_mapping = {
"image_size": "size",
"media_type": "output_format",
"model_id": "model",
"streaming_count": "partial_images",
}
# count and response_format are not supported by Responses API
for key, value in tool.options.items():
mapped_key = option_mapping.get(key, key)
mapped_tool[mapped_key] = value
if tool.additional_properties:
mapped_tool.update(tool.additional_properties)
response_tools.append(mapped_tool)
case _:
logger.debug("Unsupported tool passed (type: %s)", type(tool))
else:
# Handle raw dictionary tools
tool_dict = tool if isinstance(tool, dict) else dict(tool)
response_tools.append(tool_dict)
# Special handling for image_generation tools
if tool_dict.get("type") == "image_generation":
# Create a copy to avoid modifying the original
mapped_tool = tool_dict.copy()
# Map user-friendly parameter names to OpenAI API parameter names
parameter_mapping = {
"format": "output_format",
"compression": "output_compression",
}
for user_param, api_param in parameter_mapping.items():
if user_param in mapped_tool:
# Map the parameter name and remove the old one
mapped_tool[api_param] = mapped_tool.pop(user_param)
# Validate partial_images parameter for streaming image generation
# OpenAI API requires partial_images to be between 0-3 (inclusive) for image_generation tool
# Reference: https://platform.openai.com/docs/api-reference/responses/create#responses_create-tools-image_generation_tool-partial_images
if "partial_images" in mapped_tool:
partial_images = mapped_tool["partial_images"]
if not isinstance(partial_images, int) or partial_images < 0 or partial_images > 3:
raise ValueError("partial_images must be an integer between 0 and 3 (inclusive).")
response_tools.append(mapped_tool)
else:
response_tools.append(tool_dict)
return response_tools
@staticmethod
@@ -452,23 +439,16 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
if (tool_choice := run_options.get("tool_choice")) and isinstance(tool_choice, dict) and "mode" in tool_choice:
run_options["tool_choice"] = tool_choice["mode"]
# additional properties (excluding response_format which is handled separately)
# additional properties
additional_options = {
key: value
for key, value in chat_options.additional_properties.items()
if value is not None and key != "response_format"
key: value for key, value in chat_options.additional_properties.items() if value is not None
}
if additional_options:
run_options.update(additional_options)
# response format and text config (after additional_properties so user can pass text via additional_properties)
# Check both chat_options.response_format and additional_properties for response_format
response_format: Any = (
chat_options.response_format
if chat_options.response_format is not None
else chat_options.additional_properties.get("response_format")
)
text_config: Any = run_options.pop("text", None)
response_format = chat_options.response_format
text_config = run_options.pop("text", None)
response_format, text_config = self._prepare_response_and_text_format(
response_format=response_format, text_config=text_config
)
@@ -571,10 +551,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
if status := props.get("status"):
ret["status"] = status
if reasoning_text := props.get("reasoning_text"):
ret["content"] = {
"type": "reasoning_text",
"text": reasoning_text,
}
ret["content"] = {"type": "reasoning_text", "text": reasoning_text}
if encrypted_content := props.get("encrypted_content"):
ret["encrypted_content"] = encrypted_content
return ret
@@ -620,17 +597,9 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
return file_obj
return {}
case FunctionCallContent():
if not content.call_id:
logger.warning(f"FunctionCallContent missing call_id for function '{content.name}'")
return {}
# Use fc_id from additional_properties if available, otherwise fallback to call_id
fc_id = call_id_to_id.get(content.call_id, content.call_id)
# OpenAI Responses API requires IDs to start with `fc_`
if not fc_id.startswith("fc_"):
fc_id = f"fc_{fc_id}"
return {
"call_id": content.call_id,
"id": fc_id,
"id": call_id_to_id[content.call_id],
"type": "function_call",
"name": content.name,
"arguments": content.arguments,
@@ -766,17 +735,11 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
)
)
case _:
logger.debug(
"Unparsed annotation type: %s",
annotation.type,
)
logger.debug("Unparsed annotation type: %s", annotation.type)
contents.append(text_content)
case "refusal":
contents.append(
TextContent(
text=message_content.refusal,
raw_representation=message_content,
)
TextContent(text=message_content.refusal, raw_representation=message_content)
)
case "reasoning": # ResponseOutputReasoning
if hasattr(item, "content") and item.content:
@@ -797,40 +760,22 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
TextReasoningContent(text=summary.text, raw_representation=summary) # type: ignore[arg-type]
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(item, "call_id", None) or getattr(item, "id", None)
outputs: list["Contents"] = []
if item_outputs := getattr(item, "outputs", None):
for code_output in item_outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
text=code_output.logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
if hasattr(item, "outputs") and item.outputs:
for code_output in item.outputs:
if code_output.type == "logs":
contents.append(TextContent(text=code_output.logs, raw_representation=item))
if code_output.type == "image":
contents.append(
UriContent(
uri=code_output.url,
raw_representation=code_output,
raw_representation=item,
# no more specific media type then this can be inferred
media_type="image",
)
)
if code := getattr(item, "code", None):
contents.append(
CodeInterpreterToolCallContent(
call_id=call_id,
inputs=[TextContent(text=code, raw_representation=item)],
raw_representation=item,
)
)
contents.append(
CodeInterpreterToolResultContent(
call_id=call_id,
outputs=outputs,
raw_representation=item,
)
)
elif hasattr(item, "code") and item.code:
# fallback if no output was returned is the code:
contents.append(TextContent(text=item.code, raw_representation=item))
case "function_call": # ResponseOutputFunctionCall
contents.append(
FunctionCallContent(
@@ -854,49 +799,31 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
),
)
)
case "mcp_call":
call_id = item.id
contents.append(
MCPServerToolCallContent(
call_id=call_id,
tool_name=item.name,
server_name=item.server_label,
arguments=item.arguments,
raw_representation=item,
)
)
if item.output is not None:
case "image_generation_call": # ResponseOutputImageGenerationCall
if item.result:
# Handle the result as either a proper data URI or raw base64 string
uri = item.result
media_type = None
if not uri.startswith("data:"):
# Raw base64 string - convert to proper data URI format using helper
uri, media_type = DataContent.create_data_uri_from_base64(uri)
else:
# Parse media type from existing data URI
try:
# Extract media type from data URI (e.g., "data:image/png;base64,...")
if ";" in uri and uri.startswith("data:"):
media_type = uri.split(";")[0].split(":", 1)[1]
except Exception:
# Fallback if parsing fails
media_type = "image"
contents.append(
MCPServerToolResultContent(
call_id=call_id,
output=[TextContent(text=item.output)],
DataContent(
uri=uri,
media_type=media_type,
raw_representation=item,
)
)
case "image_generation_call": # ResponseOutputImageGenerationCall
image_output: DataContent | None = None
if item.result:
base64_data = item.result
image_format = DataContent.detect_image_format_from_base64(base64_data)
image_output = DataContent(
data=base64_data,
media_type=f"image/{image_format}" if image_format else "image/png",
raw_representation=item.result,
)
image_id = item.id
contents.append(
ImageGenerationToolCallContent(
image_id=image_id,
raw_representation=item,
)
)
contents.append(
ImageGenerationToolResultContent(
image_id=image_id,
outputs=image_output,
raw_representation=item,
)
)
# TODO(peterychang): Add support for other content types
case _:
logger.debug("Unparsed output of type: %s: %s", item.type, item)
response_message = ChatMessage(role="assistant", contents=contents)
@@ -1046,10 +973,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# McpApprovalRequest,
# ResponseCustomToolCall,
case "function_call":
function_call_ids[event.output_index] = (
event_item.call_id,
event_item.name,
)
function_call_ids[event.output_index] = (event_item.call_id, event_item.name)
case "mcp_approval_request":
contents.append(
FunctionApprovalRequestContent(
@@ -1063,78 +987,23 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
),
)
)
case "mcp_call":
call_id = getattr(event_item, "id", None) or getattr(event_item, "call_id", None) or ""
contents.append(
MCPServerToolCallContent(
call_id=call_id,
tool_name=getattr(event_item, "name", "") or "",
server_name=getattr(event_item, "server_label", None),
arguments=getattr(event_item, "arguments", None),
raw_representation=event_item,
)
)
result_output = (
getattr(event_item, "result", None)
or getattr(event_item, "output", None)
or getattr(event_item, "outputs", None)
)
parsed_output: list[Contents] | None = None
if result_output:
normalized = (
result_output
if isinstance(result_output, Sequence)
and not isinstance(result_output, (str, bytes, MutableMapping))
else [result_output]
)
parsed_output = [_parse_content(output_item) for output_item in normalized]
contents.append(
MCPServerToolResultContent(
call_id=call_id,
output=parsed_output,
raw_representation=event_item,
)
)
case "code_interpreter_call": # ResponseOutputCodeInterpreterCall
call_id = getattr(event_item, "call_id", None) or getattr(event_item, "id", None)
outputs: list[Contents] = []
if hasattr(event_item, "outputs") and event_item.outputs:
for code_output in event_item.outputs:
if getattr(code_output, "type", None) == "logs":
outputs.append(
TextContent(
text=cast(Any, code_output).logs,
raw_representation=code_output,
)
)
elif getattr(code_output, "type", None) == "image":
outputs.append(
if code_output.type == "logs":
contents.append(TextContent(text=code_output.logs, raw_representation=event_item))
if code_output.type == "image":
contents.append(
UriContent(
uri=cast(Any, code_output).url,
raw_representation=code_output,
uri=code_output.url,
raw_representation=event_item,
# no more specific media type then this can be inferred
media_type="image",
)
)
if hasattr(event_item, "code") and event_item.code:
contents.append(
CodeInterpreterToolCallContent(
call_id=call_id,
inputs=[
TextContent(
text=event_item.code,
raw_representation=event_item,
)
],
raw_representation=event_item,
)
)
contents.append(
CodeInterpreterToolResultContent(
call_id=call_id,
outputs=outputs,
raw_representation=event_item,
)
)
elif hasattr(event_item, "code") and event_item.code:
# fallback if no output was returned is the code:
contents.append(TextContent(text=event_item.code, raw_representation=event_item))
case "reasoning": # ResponseOutputReasoning
if hasattr(event_item, "content") and event_item.content:
for index, reasoning_content in enumerate(event_item.content):
@@ -1162,10 +1031,7 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
call_id=call_id,
name=name,
arguments=event.delta,
additional_properties={
"output_index": event.output_index,
"fc_id": event.item_id,
},
additional_properties={"output_index": event.output_index, "fc_id": event.item_id},
raw_representation=event,
)
)
@@ -1177,27 +1043,14 @@ class OpenAIBaseResponsesClient(OpenAIBase, BaseChatClient):
# Use helper function to create data URI from base64
uri, media_type = DataContent.create_data_uri_from_base64(image_base64)
image_output = DataContent(
uri=uri,
media_type=media_type,
additional_properties={
"partial_image_index": partial_index,
"is_partial_image": True,
},
raw_representation=event,
)
image_id = getattr(event, "item_id", None)
contents.append(
ImageGenerationToolCallContent(
image_id=image_id,
raw_representation=event,
)
)
contents.append(
ImageGenerationToolResultContent(
image_id=image_id,
outputs=image_output,
DataContent(
uri=uri,
media_type=media_type,
additional_properties={
"partial_image_index": partial_index,
"is_partial_image": True,
},
raw_representation=event,
)
)
+1 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260107"
version = "1.0.0b251223"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -50,7 +50,6 @@ all = [
"agent-framework-copilotstudio",
"agent-framework-declarative",
"agent-framework-devui",
"agent-framework-durabletask",
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-ollama",
@@ -552,24 +552,26 @@ async def test_azure_responses_client_agent_chat_options_agent_level() -> None:
async def test_azure_responses_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with Azure Response Agent using Microsoft Learn MCP."""
mcp_tool = HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
)
async with ChatAgent(
chat_client=AzureOpenAIResponsesClient(credential=AzureCliCredential()),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
tools=[mcp_tool],
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
# this needs to be high enough to handle the full MCP tool response.
max_tokens=5000,
max_tokens=200,
)
assert isinstance(response, AgentRunResponse)
assert response.text
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@@ -10,7 +10,6 @@ from pydantic import BaseModel
from agent_framework import (
AIFunction,
HostedCodeInterpreterTool,
HostedImageGenerationTool,
HostedMCPTool,
ToolProtocol,
ai_function,
@@ -819,30 +818,6 @@ def test_hosted_code_interpreter_tool_with_unknown_input():
HostedCodeInterpreterTool(inputs={"hosted_file": "file-single"})
def test_hosted_image_generation_tool_defaults():
"""HostedImageGenerationTool should default name and empty description."""
tool = HostedImageGenerationTool()
assert tool.name == "image_generation"
assert tool.description == ""
assert tool.options is None
assert str(tool) == "HostedImageGenerationTool(name=image_generation)"
def test_hosted_image_generation_tool_with_options():
"""HostedImageGenerationTool should store options."""
tool = HostedImageGenerationTool(
description="Generate images",
options={"format": "png", "size": "1024x1024"},
additional_properties={"quality": "high"},
)
assert tool.name == "image_generation"
assert tool.description == "Generate images"
assert tool.options == {"format": "png", "size": "1024x1024"}
assert tool.additional_properties == {"quality": "high"}
# region HostedMCPTool tests
@@ -18,8 +18,6 @@ from agent_framework import (
ChatResponse,
ChatResponseUpdate,
CitationAnnotation,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
DataContent,
ErrorContent,
FinishReason,
@@ -29,10 +27,6 @@ from agent_framework import (
FunctionResultContent,
HostedFileContent,
HostedVectorStoreContent,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
MCPServerToolCallContent,
MCPServerToolResultContent,
Role,
TextContent,
TextReasoningContent,
@@ -275,78 +269,6 @@ def test_hosted_file_content_minimal():
assert isinstance(content, BaseContent)
def test_hosted_file_content_optional_fields():
"""HostedFileContent should capture optional media type and name."""
content = HostedFileContent(file_id="file-789", media_type="image/png", name="plot.png")
assert content.media_type == "image/png"
assert content.name == "plot.png"
assert content.has_top_level_media_type("image")
assert content.has_top_level_media_type("application") is False
# region: CodeInterpreter content
def test_code_interpreter_tool_call_content_parses_inputs():
call = CodeInterpreterToolCallContent(
call_id="call-1",
inputs=[{"type": "text", "text": "print('hi')"}],
)
assert call.type == "code_interpreter_tool_call"
assert call.call_id == "call-1"
assert call.inputs and isinstance(call.inputs[0], TextContent)
assert call.inputs[0].text == "print('hi')"
def test_code_interpreter_tool_result_content_outputs():
result = CodeInterpreterToolResultContent(
call_id="call-2",
outputs=[
{"type": "text", "text": "log output"},
{"type": "uri", "uri": "https://example.com/file.png", "media_type": "image/png"},
],
)
assert result.type == "code_interpreter_tool_result"
assert result.call_id == "call-2"
assert result.outputs is not None
assert isinstance(result.outputs[0], TextContent)
assert isinstance(result.outputs[1], UriContent)
# region: Image generation content
def test_image_generation_tool_contents():
call = ImageGenerationToolCallContent(image_id="img-1")
outputs = [DataContent(data=b"1234", media_type="image/png")]
result = ImageGenerationToolResultContent(image_id="img-1", outputs=outputs)
assert call.type == "image_generation_tool_call"
assert call.image_id == "img-1"
assert result.type == "image_generation_tool_result"
assert result.image_id == "img-1"
assert result.outputs and isinstance(result.outputs[0], DataContent)
# region: MCP server tool content
def test_mcp_server_tool_call_and_result():
call = MCPServerToolCallContent(call_id="c-1", tool_name="tool", server_name="server", arguments={"x": 1})
assert call.type == "mcp_server_tool_call"
assert call.arguments == {"x": 1}
result = MCPServerToolResultContent(call_id="c-1", output=[{"type": "text", "text": "done"}])
assert result.type == "mcp_server_tool_result"
assert result.output
with raises(ValueError):
MCPServerToolCallContent(call_id="", tool_name="tool")
# region: HostedVectorStoreContent
@@ -547,15 +469,6 @@ def test_function_approval_serialization_roundtrip():
# The Contents union will need to be handled differently when we fully migrate
def test_function_approval_accepts_mcp_call():
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
mcp_call = MCPServerToolCallContent(call_id="c-mcp", tool_name="tool", server_name="srv", arguments={"x": 1})
req = FunctionApprovalRequestContent(id="req-mcp", function_call=mcp_call)
assert isinstance(req.function_call, MCPServerToolCallContent)
assert req.function_call.call_id == "c-mcp"
# region BaseContent Serialization
@@ -2220,55 +2133,3 @@ def test_prepare_function_call_results_nested_pydantic_model():
assert "Seattle" in json_result
assert "rainy" in json_result
assert "18.0" in json_result or "18" in json_result
# region prepare_function_call_results with MCP TextContent-like objects
def test_prepare_function_call_results_text_content_single():
"""Test that objects with text attribute (like MCP TextContent) are properly handled."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
result = [MockTextContent("Hello from MCP tool!")]
json_result = prepare_function_call_results(result)
# Should extract text and serialize as JSON array of strings
assert isinstance(json_result, str)
assert json_result == '["Hello from MCP tool!"]'
def test_prepare_function_call_results_text_content_multiple():
"""Test that multiple TextContent-like objects are serialized correctly."""
from dataclasses import dataclass
@dataclass
class MockTextContent:
text: str
result = [MockTextContent("First result"), MockTextContent("Second result")]
json_result = prepare_function_call_results(result)
# Should extract text from each and serialize as JSON array
assert isinstance(json_result, str)
assert json_result == '["First result", "Second result"]'
def test_prepare_function_call_results_text_content_with_non_string_text():
"""Test that objects with non-string text attribute are not treated as TextContent."""
class BadTextContent:
def __init__(self):
self.text = 12345 # Not a string!
result = [BadTextContent()]
json_result = prepare_function_call_results(result)
# Should not extract text since it's not a string, will serialize the object
assert isinstance(json_result, str)
# endregion
@@ -26,8 +26,6 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
CodeInterpreterToolCallContent,
CodeInterpreterToolResultContent,
DataContent,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
@@ -36,12 +34,9 @@ from agent_framework import (
HostedCodeInterpreterTool,
HostedFileContent,
HostedFileSearchTool,
HostedImageGenerationTool,
HostedMCPTool,
HostedVectorStoreContent,
HostedWebSearchTool,
ImageGenerationToolCallContent,
ImageGenerationToolResultContent,
MCPStreamableHTTPTool,
Role,
TextContent,
@@ -617,14 +612,11 @@ def test_response_content_creation_with_code_interpreter() -> None:
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, CodeInterpreterToolCallContent)
assert call_content.inputs is not None
assert isinstance(call_content.inputs[0], TextContent)
assert isinstance(result_content, CodeInterpreterToolResultContent)
assert result_content.outputs is not None
assert any(isinstance(out, TextContent) for out in result_content.outputs)
assert any(isinstance(out, UriContent) for out in result_content.outputs)
assert isinstance(response.messages[0].contents[0], TextContent)
assert response.messages[0].contents[0].text == "Code execution log"
assert isinstance(response.messages[0].contents[1], UriContent)
assert response.messages[0].contents[1].uri == "https://example.com/image.png"
assert response.messages[0].contents[1].media_type == "image"
def test_response_content_creation_with_function_call() -> None:
@@ -769,13 +761,14 @@ def test_prepare_tools_for_openai_with_raw_image_generation() -> None:
"""Test that raw image_generation tool dict is handled correctly with parameter mapping."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
# Test with raw tool dict using OpenAI parameters directly
# Test with raw tool dict using user-friendly parameter names
tool = {
"type": "image_generation",
"size": "1536x1024",
"quality": "high",
"output_format": "webp",
"output_quality": 75,
"format": "webp", # Will be mapped to output_format
"compression": 75, # Will be mapped to output_compression
"background": "transparent",
}
resp_tools = client._prepare_tools_for_openai([tool])
@@ -787,8 +780,10 @@ def test_prepare_tools_for_openai_with_raw_image_generation() -> None:
assert image_tool["type"] == "image_generation"
assert image_tool["size"] == "1536x1024"
assert image_tool["quality"] == "high"
assert image_tool["background"] == "transparent"
# Check parameter name mapping
assert image_tool["output_format"] == "webp"
assert image_tool["output_quality"] == 75
assert image_tool["output_compression"] == 75
def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_params() -> None:
@@ -802,7 +797,7 @@ def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_par
"model": "gpt-image-1",
"input_fidelity": "high",
"moderation": "strict",
"output_format": "png",
"partial_images": 2, # Should be integer 0-3
}
resp_tools = client._prepare_tools_for_openai([tool])
@@ -820,7 +815,7 @@ def test_prepare_tools_for_openai_with_raw_image_generation_openai_responses_par
assert tool_dict["model"] == "gpt-image-1"
assert tool_dict["input_fidelity"] == "high"
assert tool_dict["moderation"] == "strict"
assert tool_dict["output_format"] == "png"
assert tool_dict["partial_images"] == 2
def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None:
@@ -841,24 +836,6 @@ def test_prepare_tools_for_openai_with_raw_image_generation_minimal() -> None:
assert len(image_tool) == 1
def test_prepare_tools_for_openai_with_hosted_image_generation() -> None:
"""Test HostedImageGenerationTool conversion."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
tool = HostedImageGenerationTool(
description="Generate images",
options={"output_format": "png", "size": "512x512"},
additional_properties={"quality": "high"},
)
resp_tools = client._prepare_tools_for_openai([tool])
assert len(resp_tools) == 1
image_tool = resp_tools[0]
assert image_tool["type"] == "image_generation"
assert image_tool["output_format"] == "png"
assert image_tool["size"] == "512x512"
assert image_tool["quality"] == "high"
def test_parse_chunk_from_openai_with_mcp_approval_request() -> None:
"""Test that a streaming mcp_approval_request event is parsed into FunctionApprovalRequestContent."""
client = OpenAIResponsesClient(model_id="test-model", api_key="test-key")
@@ -1301,11 +1278,9 @@ def test_parse_chunk_from_openai_code_interpreter() -> None:
result = client._parse_chunk_from_openai(mock_event_image, chat_options, function_call_ids) # type: ignore
assert len(result.contents) == 1
assert isinstance(result.contents[0], CodeInterpreterToolResultContent)
assert result.contents[0].outputs
assert any(
isinstance(out, UriContent) and out.uri == "https://example.com/plot.png" for out in result.contents[0].outputs
)
assert isinstance(result.contents[0], UriContent)
assert result.contents[0].uri == "https://example.com/plot.png"
assert result.contents[0].media_type == "image"
def test_parse_chunk_from_openai_reasoning() -> None:
@@ -1520,16 +1495,12 @@ def test_parse_response_from_openai_image_generation_raw_base64():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.uri.startswith("data:image/png;base64,")
assert data_out.media_type == "image/png"
# Verify the response contains DataContent with proper URI and media_type
assert len(response.messages[0].contents) == 1
content = response.messages[0].contents[0]
assert isinstance(content, DataContent)
assert content.uri.startswith("data:image/png;base64,")
assert content.media_type == "image/png"
def test_parse_response_from_openai_image_generation_existing_data_uri():
@@ -1550,23 +1521,19 @@ def test_parse_response_from_openai_image_generation_existing_data_uri():
valid_webp_base64 = base64.b64encode(webp_signature + b"VP8 fake_data").decode()
mock_item = MagicMock()
mock_item.type = "image_generation_call"
mock_item.result = valid_webp_base64
mock_item.result = f"data:image/webp;base64,{valid_webp_base64}"
mock_response.output = [mock_item]
with patch.object(client, "_get_metadata_from_response", return_value={}):
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
# Verify the response contains call + result with DataContent output
assert len(response.messages[0].contents) == 2
call_content, result_content = response.messages[0].contents
assert isinstance(call_content, ImageGenerationToolCallContent)
assert isinstance(result_content, ImageGenerationToolResultContent)
assert result_content.outputs
data_out = result_content.outputs
assert isinstance(data_out, DataContent)
assert data_out.uri == f"data:image/webp;base64,{valid_webp_base64}"
assert data_out.media_type == "image/webp"
# Verify the response contains DataContent with proper media_type parsed from URI
assert len(response.messages[0].contents) == 1
content = response.messages[0].contents[0]
assert isinstance(content, DataContent)
assert content.uri == f"data:image/webp;base64,{valid_webp_base64}"
assert content.media_type == "image/webp"
def test_parse_response_from_openai_image_generation_format_detection():
@@ -1592,12 +1559,10 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_jpeg = client._parse_response_from_openai(mock_response_jpeg, chat_options=ChatOptions()) # type: ignore
result_contents = response_jpeg.messages[0].contents
assert isinstance(result_contents[1], ImageGenerationToolResultContent)
outputs = result_contents[1].outputs
assert outputs and isinstance(outputs, DataContent)
assert outputs.media_type == "image/jpeg"
assert "data:image/jpeg;base64," in outputs.uri
content_jpeg = response_jpeg.messages[0].contents[0]
assert isinstance(content_jpeg, DataContent)
assert content_jpeg.media_type == "image/jpeg"
assert "data:image/jpeg;base64," in content_jpeg.uri
# Test WEBP detection
webp_signature = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP"
@@ -1618,10 +1583,10 @@ def test_parse_response_from_openai_image_generation_format_detection():
with patch.object(client, "_get_metadata_from_response", return_value={}):
response_webp = client._parse_response_from_openai(mock_response_webp, chat_options=ChatOptions()) # type: ignore
outputs_webp = response_webp.messages[0].contents[1].outputs
assert outputs_webp and isinstance(outputs_webp, DataContent)
assert outputs_webp.media_type == "image/webp"
assert "data:image/webp;base64," in outputs_webp.uri
content_webp = response_webp.messages[0].contents[0]
assert isinstance(content_webp, DataContent)
assert content_webp.media_type == "image/webp"
assert "data:image/webp;base64," in content_webp.uri
def test_parse_response_from_openai_image_generation_fallback():
@@ -1650,11 +1615,9 @@ def test_parse_response_from_openai_image_generation_fallback():
response = client._parse_response_from_openai(mock_response, chat_options=ChatOptions()) # type: ignore
# Verify it falls back to PNG format for unrecognized binary data
assert len(response.messages[0].contents) == 2
result_content = response.messages[0].contents[1]
assert isinstance(result_content, ImageGenerationToolResultContent)
assert result_content.outputs
content = result_content.outputs
assert len(response.messages[0].contents) == 1
content = response.messages[0].contents[0]
assert isinstance(content, DataContent)
assert content.media_type == "image/png"
assert f"data:image/png;base64,{unrecognized_base64}" == content.uri
@@ -2190,30 +2153,38 @@ async def test_openai_responses_client_agent_hosted_code_interpreter_tool():
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_image_generation_tool():
async def test_openai_responses_client_agent_raw_image_generation_tool():
"""Test OpenAI Responses Client agent with raw image_generation tool through OpenAIResponsesClient."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can generate images.",
tools=HostedImageGenerationTool(options={"image_size": "1024x1024", "media_type": "png"}),
tools=[{"type": "image_generation", "size": "1024x1024", "quality": "low", "format": "png"}],
) as agent:
# Test image generation functionality
response = await agent.run("Generate an image of a cute red panda sitting on a tree branch in a forest.")
assert isinstance(response, AgentRunResponse)
assert response.messages
# Verify we got image content - look for ImageGenerationToolResultContent
# For image generation, we expect to get some response content
# This could be DataContent with image data, UriContent
assert response.messages is not None and len(response.messages) > 0
# Check that we have some kind of content in the response
total_contents = sum(len(message.contents) for message in response.messages)
assert total_contents > 0, f"Expected some content in response messages, got {total_contents} contents"
# Verify we got image content - look for DataContent with URI starting with "data:image"
image_content_found = False
for message in response.messages:
for content in message.contents:
if content.type == "image_generation_tool_result" and content.outputs:
uri = getattr(content, "uri", None)
if uri and uri.startswith("data:image"):
image_content_found = True
break
if image_content_found:
break
# The test passes if we got image content
# The test passes if we got image content (which we did based on the visible base64 output)
assert image_content_found, "Expected to find image content in response"
@@ -2335,24 +2306,26 @@ async def test_openai_responses_client_agent_chat_options_agent_level() -> None:
async def test_openai_responses_client_agent_hosted_mcp_tool() -> None:
"""Integration test for HostedMCPTool with OpenAI Response Agent using Microsoft Learn MCP."""
mcp_tool = HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
)
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that can help with microsoft documentation questions.",
tools=HostedMCPTool(
name="Microsoft Learn MCP",
url="https://learn.microsoft.com/api/mcp",
description="A Microsoft Learn MCP server for documentation questions",
approval_mode="never_require",
),
tools=[mcp_tool],
) as agent:
response = await agent.run(
"How to create an Azure storage account using az cli?",
# this needs to be high enough to handle the full MCP tool response.
max_tokens=5000,
max_tokens=200,
)
assert isinstance(response, AgentRunResponse)
assert response.text
assert response.text is not None
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
@@ -2382,91 +2355,3 @@ async def test_openai_responses_client_agent_local_mcp_tool() -> None:
assert len(response.text) > 0
# Should contain Azure-related content since it's asking about Azure CLI
assert any(term in response.text.lower() for term in ["azure", "storage", "account", "cli"])
class ReleaseBrief(BaseModel):
"""Structured output model for release brief testing."""
title: str
summary: str
highlights: list[str]
model_config = {"extra": "forbid"}
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_with_response_format_pydantic() -> None:
"""Integration test for response_format with Pydantic model using OpenAI Responses Client."""
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="You are a helpful assistant that returns structured JSON responses.",
) as agent:
response = await agent.run(
"Summarize the following release notes into a ReleaseBrief:\n\n"
"Version 2.0 Release Notes:\n"
"- Added new streaming API for real-time responses\n"
"- Improved error handling with detailed messages\n"
"- Performance boost of 50% in batch processing\n"
"- Fixed memory leak in connection pooling",
response_format=ReleaseBrief,
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.value is not None
assert isinstance(response.value, ReleaseBrief)
# Validate structured output fields
brief = response.value
assert len(brief.title) > 0
assert len(brief.summary) > 0
assert len(brief.highlights) > 0
@pytest.mark.flaky
@skip_if_openai_integration_tests_disabled
async def test_openai_responses_client_agent_with_runtime_json_schema() -> None:
"""Integration test for response_format with runtime JSON schema using OpenAI Responses Client."""
runtime_schema = {
"title": "WeatherDigest",
"type": "object",
"properties": {
"location": {"type": "string"},
"conditions": {"type": "string"},
"temperature_c": {"type": "number"},
"advisory": {"type": "string"},
},
"required": ["location", "conditions", "temperature_c", "advisory"],
"additionalProperties": False,
}
async with ChatAgent(
chat_client=OpenAIResponsesClient(),
instructions="Return only JSON that matches the provided schema. Do not add commentary.",
) as agent:
response = await agent.run(
"Give a brief weather digest for Seattle.",
additional_chat_options={
"response_format": {
"type": "json_schema",
"json_schema": {
"name": runtime_schema["title"],
"strict": True,
"schema": runtime_schema,
},
},
},
)
# Validate response
assert isinstance(response, AgentRunResponse)
assert response.text is not None
# Parse JSON and validate structure
import json
parsed = json.loads(response.text)
assert "location" in parsed
assert "conditions" in parsed
assert "temperature_c" in parsed
assert "advisory" in parsed
@@ -702,84 +702,6 @@ class TestWorkflowAgent:
assert unique_text_count == 1, f"Response should appear exactly once, but appeared {unique_text_count} times"
class TestWorkflowAgentAuthorName:
"""Test cases for author_name enrichment in WorkflowAgent (GitHub issue #1331)."""
async def test_agent_run_update_event_gets_executor_id_as_author_name(self):
"""Test that AgentRunUpdateEvent gets executor_id as author_name when not already set.
This validates the fix for GitHub issue #1331: agent responses should include
identification of which agent produced them in multi-agent workflows.
"""
# Create workflow with executor that emits AgentRunUpdateEvent without author_name
executor1 = SimpleExecutor(id="my_executor_id", response_text="Response", emit_streaming=False)
workflow = WorkflowBuilder().set_start_executor(executor1).build()
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
# Collect streaming updates
updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream("Hello"):
updates.append(update)
# Verify at least one update was received
assert len(updates) >= 1
# Verify author_name is set to executor_id
assert updates[0].author_name == "my_executor_id"
async def test_agent_run_update_event_preserves_existing_author_name(self):
"""Test that existing author_name is preserved and not overwritten."""
class AuthorNameExecutor(Executor):
"""Executor that sets author_name explicitly."""
@handler
async def handle_message(self, message: list[ChatMessage], ctx: WorkflowContext[list[ChatMessage]]) -> None:
# Emit update with explicit author_name
update = AgentRunResponseUpdate(
contents=[TextContent(text="Response with author")],
role=Role.ASSISTANT,
author_name="custom_author_name", # Explicitly set
message_id=str(uuid.uuid4()),
)
await ctx.add_event(AgentRunUpdateEvent(executor_id=self.id, data=update))
executor = AuthorNameExecutor(id="executor_id")
workflow = WorkflowBuilder().set_start_executor(executor).build()
agent = WorkflowAgent(workflow=workflow, name="Test Agent")
# Collect streaming updates
updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream("Hello"):
updates.append(update)
# Verify author_name is preserved (not overwritten with executor_id)
assert len(updates) >= 1
assert updates[0].author_name == "custom_author_name"
async def test_multiple_executors_have_distinct_author_names(self):
"""Test that multiple executors in a workflow have their own author_name."""
# Create workflow with two executors
executor1 = SimpleExecutor(id="first_executor", response_text="First", emit_streaming=False)
executor2 = SimpleExecutor(id="second_executor", response_text="Second", emit_streaming=False)
workflow = WorkflowBuilder().set_start_executor(executor1).add_edge(executor1, executor2).build()
agent = WorkflowAgent(workflow=workflow, name="Multi-Executor Agent")
# Collect streaming updates
updates: list[AgentRunResponseUpdate] = []
async for update in agent.run_stream("Hello"):
updates.append(update)
# Should have updates from both executors
assert len(updates) >= 2
# Verify each update has the correct author_name matching its executor
author_names = [u.author_name for u in updates]
assert "first_executor" in author_names
assert "second_executor" in author_names
class TestWorkflowAgentMergeUpdates:
"""Test cases specifically for the WorkflowAgent.merge_updates static method."""

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