Compare commits

..
Author SHA1 Message Date
+44 172423aab7 .NET: Python: Merge main into feature-durabletask branch (#3385)
* 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

* 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

* Capture file IDs from code interpreter in streaming responses (#2741)

* .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)

* prevent nulls in AIAgent property

* address feedback

* code ql sm04598 (#2723)

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .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>

* 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>

* 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>

* 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>

* 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

* Python: Updated package versions (#2784)

* Updated package versions

* Small fix

* 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>

* .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>

* 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

* Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774)

* 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/

* Python: Remove warnings from workflow builder on not using factories (#2808)

* Revert concurrent

* Fix comments

* Python: Filter framework kwargs from MCP tool invocations (#2870)

* Filter framework kwargs from MCP tool invocations

* Fixes

* 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

* 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>

* 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

* 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

* 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

* .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>

* 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>

* 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>

* 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>

* 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>

* Skip failing IT (#2904)

* .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

* .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

* 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

* Updated package versions (#2913)

* .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.

* 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.

* [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

* 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

* Fix Pydantic error when using Literal type for tool params (#2893)

* Updated Ollama package version (#2920)

* Python: Azure AI Agent with Bing Grounding Citations Sample (#2892)

* bing grounding sample with citations

* small fix

* fix

* .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>

* Added additional arguments for Azure AI agent (#2922)

* 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

* Pass kwargs into subworkflows (#2923)

* Python: Move ollama samples to samples getting started dir (#2921)

* Move ollama samples to samples getting started dir

* Address feedback

* 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>

* .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>

* 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

* Python: Workflow add option to visualize internal executors (#2917)

* Workflow add option to visualize internal executors

* Address Copilot comments

* 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

* 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

* .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>

* Python: Add Azure Managed Redis Support with Credential Provider (#2887)

* azure redis support

* small fixes

* azure managed redis sample

* fixes

* 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>

* 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>

* 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>

* Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946)

* Fix kwargs propagation through workflow.as_agent()

* Fix WorkflowAgent to respect AgentExecutor output_response setting

* .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

* 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

* 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

* Updated package versions (#2978)

* Python: Added GitHub MCP sample with PAT (#2967)

* added github mcp sample with PAT

* addressed copilot fixes

* env fix

* 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

* 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

* 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>

* .NET: [Durable Agents] Reliable streaming sample (#2942)

* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback

* 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>

* Python: latency improvements (#3014)

* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement

* Python: Updated package versions (#3024)

* Updated package versions

* Updated changelog

* Python: add powerfx safe mode (#3028)

* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset

* .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>

* add issue template and additional labeling (#3006)

* fix and extra int test (#3037)

* .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>

* [BREAKING] Remove unused AgentThreadMetadata (#3067)

* Remove unused AgentThreadMetadata

* Update DurableTask Changelog

* 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

* Fix: Update OTLP exporter protocol conditions (#3070)

* Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)

* Fix ExecutorInvokedEvent.data mutation bug

* Fix bug related to not yielding output type

* .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>

* Fix broken strands urls. (#3102)

* Fix broken strands urls.

* Fix typos

* .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>

* fix: tool_choice parameter not being honored when passed to agent.run() (#3095)

* sharepoint sample fix (#3108)

* Bump versions to 1.0.0b260106 for a release. Update CHANGELOG.md (#3109)

* Bump Bedrock version to latest (#3110)

* Python: Fix MCP tool result serialization for list[TextContent] (#2523)

* Fix MCP tool result serialization for list[TextContent]

When MCP tools return results containing list[TextContent], they were
incorrectly serialized to object repr strings like:
'[<agent_framework._types.TextContent object at 0x...>]'

This fix properly extracts text content from list items by:
1. Checking if items have a 'text' attribute (TextContent)
2. Using model_dump() for items that support it
3. Falling back to str() for other types
4. Joining single items as plain text, multiple items as JSON array

Fixes #2509

* Address PR review feedback for MCP tool result serialization

- Extract serialize_content_result() to shared _utils.py
- Fix logic: use texts[0] instead of join for single item
- Add type annotation: texts: list[str] = []
- Return empty string for empty list instead of '[]'
- Move import json to file top level
- Add comprehensive unit tests for serialization

* Address PR review feedback: fix type checking and double serialization

- Add isinstance(item.text, str) check to ensure text attribute is a string
- Fix double-serialization issue by keeping model_dump results as dicts
  until final json.dumps (removes escaped JSON strings in arrays)
- Improve docstring with detailed return value documentation
- Add test for non-string text attribute handling
- Add tests for list type tool results in _events.py path

* Simplify PR: minimal changes to fix MCP tool result serialization

Addresses reviewer feedback about excessive refactoring:
- Reset _events.py to original structure
- Only add import and use serialize_content_result in one location
- All review comments addressed in serialize_content_result():
  - Added isinstance(item.text, str) check
  - Use model_dump(mode="json") to avoid double-serialization
  - Improved docstring with explicit return value documentation
  - Empty list returns "" instead of "[]"

* Refactor: Move MCP TextContent serialization to core prepare_function_call_results

Per reviewer feedback, moved the TextContent serialization logic from
ag-ui's serialize_content_result to the core package's
prepare_function_call_results function.

Changes:
- Added handling for objects with 'text' attribute (like MCP TextContent)
  in _prepare_function_call_results_as_dumpable
- Removed serialize_content_result from ag-ui/_utils.py
- Updated _events.py and _message_adapters.py to use
  prepare_function_call_results from core package
- Updated tests to match the core function's behavior

* Fix failing tests for prepare_function_call_results behavior

- test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None)
- test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class

* Fix B903 linter error: Convert MockTextContent to dataclass

The ruff linter was reporting B903 (class could be dataclass or namedtuple)
for the MockTextContent test helper classes. This commit converts them to
dataclasses to satisfy the linter check.

* Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)

* Improve DevUI, add Context Inspector view as new tab under traces

* fix mypy errors

* fix: Handle stale MCP connections in DevUI executor

MCP tools can become stale when HTTP streaming responses end - the underlying
stdio streams close but `is_connected` remains True. This causes subsequent
requests to fail with `ClosedResourceError`.

Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before
agent execution. This is a workaround for an upstream Agent Framework issue
where connection state isn't properly tracked.

Fixes MCP tools failing on second HTTP request in DevUI.

fixes  #1476 #1515 #2865

* fix #1572 report import dependency errors more clearly

* Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode?

* remove unused dead code

* improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly

* update ui build

* update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes

* .NET: Seal factory contexts and add non JSO deserialize overloads (#3066)

* Seal factory contexts and add non JSO deserialize overloads

* Apply suggestions from code review

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

---------

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

* Enable blank issues in issue template configuration

Need to re-enable creating blank issues

* updated templates (#3106)

* updated templates

* enabled blank and fixed triage

* made language optional and moved to the bottom for features

* Python: Streaming sample for azurefunctions (#3057)

* Streaming sample for azurefunctions

* Fixed links and sample name

* Addressed feedback

* Addressed feedback

* Fixed integration tests

* Updated test

* Python: fix(azure-ai): Fix response_format handling for structured outputs (#3114)

* fix(azure-ai): read response_format from chat_options instead of run_options

* refactor: use explicit None checks for response_format

* Fix mypy error

* Mypy fix

* Python: Bump python version to 1.0.0b260107 for a release (#3128)

* Bump python version to 1.0.0b260107 for a release

* Update changelog

* Make A2AAgent public, so that it's concrete implementation methods can be used. (#3119)

* .NET: Map additional props <-> A2A metadata (#3137)

* map additional props from agent run options to a2a request metadata

* small touches

* add unit tests for new extension methods

* Sort using

* add unit test

* add additiona unit tests

* special case json element to avoid unnecessary serialization

* Python: Fix Anthropic streaming response bugs (#3141)

* test commit identity

* fix(anthropic): fix raw_representation and finish_reason in streaming

* lint fix

* Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.5 to 4.0.5.1 (#2994)

---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.5.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* Bump Anthropic from 12.0.0 to 12.0.1 (#2993)

---
updated-dependencies:
- dependency-name: Anthropic
  dependency-version: 12.0.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* .NET: [Breaking] Prevent loss of input messages & streamed updates when resuming streaming (#2748)

* save input messages and stream updates to the continuation token to be able to use them in the last successful stream resumption call.

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* fix typo

* init continuation token from chat response

* remove unnecessary types for source generation

* remove check for continuation token passed at initial run

* remove check for continuation token pass at initial run

* centralize continuation token parsing

* update xml comments

* use readonly collection instead of enumerable

---------

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

* .NET: fix: Expose WorkflowErrorEvent as ErrorContent (#2762)

* fix: Expose WorkflowErrorEvent as ErrorContent

When hosted using .AsAgent(), Workflows were not exposing inner errors coming as Exceptions (through the WorkflowErrorEvent)

The fix is to convert their message to an ErrorContent on the way out, rather than rely on the default "empty update" to collect the raw event.

* feat: Add a way to show/suppress exception information

* Bump Microsoft.Agents.AI.Workflows from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1 (#2997)

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.Workflows
  dependency-version: 1.0.0-preview.251219.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* .NET: Add Run overloads to expose ChatClientAgentRunOptions in IntelliSense (#3115)

* Initial plan

* Add ChatClientAgentExtensions for improved discoverability of ChatClientAgentRunOptions

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

* Address code review feedback - use collection expression syntax

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

* Apply suggestion from @westey-m

* Fix issues with Copilot implementation

* Add additional tests for structured output overloads.

---------

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>

* Python: Add tool call/result content types and update connectors and samples (#2971)

* Add new AI content types and image tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add Python content types for tool calls/results and image generation tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address review feedback for tool content and samples

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Tighten image generation typing and sample tools list

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Align image generation output typing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Handle MCP naming, image options mapping, and connector tool content

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Allow MCP call in function approval request

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Remove raw image_generation tool remapping

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Restore Anthropic tool_use to function calls unless code execution

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix lint issues for hosted file docstring and MCP parsing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Import ChatResponse types in Anthropic client

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix Anthropics citation type imports and MCP typing for handoff/tools

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Skip lightning tests without agentlightning and fix function call import

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* fix lint on lab package

* rebuilt anthropic parsing

* redid anthropic parsing

* typo

* updated parsing and added missing docstrings

* fix tests

* mypy fixes

* second mypy fix

* add new class to other samples

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>

* Bump Google.GenAI from 0.6.0 to 0.9.0 (#2995)

---
updated-dependencies:
- dependency-name: Google.GenAI
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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>

* Bump js-yaml from 4.1.0 to 4.1.1 in /python/packages/devui/frontend (#3123)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Updated package versions (#3144)

* .NET: Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI (#2996)

* Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI

Bumps Microsoft.Agents.AI.OpenAI from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1
Bumps Microsoft.Extensions.AI.OpenAI from 10.1.0-preview.1.25608.1 to 10.1.1-preview.1.25612.2

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed samples

---------

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>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Python: fix(ag-ui): Execute tools with approval_mode, fix shared state, code cleanup  (#3079)

* fix(ag-ui): execute tools after approval in human-in-the-loop flow

* Fix shared state bug

* Bug fix finalized

* Refactoring to clean up code

* Code cleanup

* More fixes

* More code cleanup

* Add version detection in __init__.py to ruff ignore list

* Track agent name with updates for workflow agent (#3146)

* Python: Fix AzureAIClient tool call bug for AG-UI use (#3148)

* Fiz AzureAIClient tool call bug

* Address copilot feedback

* Python: multiple bug fixes (#3150)

* fix Python: kwargs are not passed to _prepare_thread_and_messages in ChatAgent.run
Fixes #3118

* fix Python: [Bug]: model_id versus model_deployment_name is confusing in Azure AI Agents
Fixes #3147

* add types

* fixed type and docstring

* fix(anthropic): fix duplicate ToolCallStartEvent in streaming tool calls (#3051)

When processing `input_json_delta` events, the Anthropic client was
passing the tool name from the previous `tool_use` event. This caused
ag-ui's `_handle_function_call_content` to emit a `ToolCallStartEvent`
for every streaming chunk (since it triggers on `if content.name:`).

This fix changes the behavior to pass an empty string for `name` in
`input_json_delta` events, matching OpenAI's behavior where streaming
argument chunks have `name=""`. The initial `tool_use` event still
provides the tool name, so only one `ToolCallStartEvent` is emitted.

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

* .NET: [BREAKING] Change GetNewThread and DeserializeThread to async (#3152)

* Change GetNewThread and DeserializeThread plus ChatMessageStore and AIContextProvider Factories to async

* Merge fixes

* Fix Ollama model env var in documentation (#3156)

Signed-off-by: Dina Suehiro Jones <dina.s.jones@intel.com>

* Python: Add Pydantic request model and OpenAPI tags support to AG-UI FastAPI endpoint (#2522)

* feat(ag-ui): Add Pydantic request model and OpenAPI tags support

- Add AGUIRequest Pydantic model in _types.py with field descriptions
- Update add_agent_framework_fastapi_endpoint() to accept tags parameter
- Use AGUIRequest model for automatic validation and OpenAPI schema generation
- Export AGUIRequest and DEFAULT_TAGS in __init__.py
- Update test_endpoint.py to expect 422 for invalid requests
- Add tests for OpenAPI schema, default tags, custom tags, and validation

Benefits:
- Better API documentation with complete request schema in Swagger UI
- Automatic request validation with Pydantic
- Organized endpoints under 'AG-UI' tag instead of 'default'
- Improved developer experience and type safety

Fixes #<issue-number>

* test(ag-ui): Add test for internal error handling to achieve 100% coverage

- Add test_endpoint_internal_error_handling() to cover exception handling code
- Mock copy.deepcopy to simulate internal error during default_state processing
- Add type: ignore for FastAPI tags parameter (known pyright compatibility issue)
- Achieves 100% test coverage for _endpoint.py (previously missing lines 103-105)

* .NET: Improve resolving `AITool` from DI (#3175)

* remove localagenttoolregistry

* also give the factory method API

* Python: Fix MCPStreamableHTTPTool to use new streamable_http_client API (#3088)

* Fix MCPStreamableHTTPTool to use new streamable_http_client API with proper httpx client cleanup

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Update docstring to reflect new streamable_http_client API usage

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Refactor MCPStreamableHTTPTool to accept optional http_client parameter and delegate client creation to streamable_http_client

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Update mcp package minimum version to 1.24.0 for streamable_http_client API support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix critical bugs: apply headers/timeout/sse_read_timeout when creating httpx client, add version constraint <2, and properly manage client lifecycle

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Simplify implementation: remove headers/timeout/sse_read_timeout params, remove kwargs, remove close() override per feedback

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add back **kwargs parameter for backward compatibility (accepted but not used)

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Remove unused httpx import from test file

Note: The uv.lock file needs to be updated with 'uv sync' to reflect the mcp version constraint change (>=1.24.0,<2)

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* cicd fixes

* udpated samples with headers examples

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>

* azureai direct a2a endpoint support (#3127)

* Python: [BREAKING]: removed display_name, renamed context_providers, middleware and AggregateContextProvider (#3139)

* removed display_name, renamed context_providers, middleware and AggregateContextProvider

* fixes

* fixed test

* testfix

* removed mistakenly put back test

* updated new test

* rename middlewares to middleware

* middleware fixes

* Python: MCP Improvements: improved connection loss behavior, pagination for loading and a param to control representation (#3154)

* pagination support (#2848) added a parse_tool_result param and connection loss (#2884)

* fix #3153

* improved connection handling

* improved logic

* Python: Add declarative workflow runtime (#2815)

* Further support for declarative python workflows

* Add tests. Clean up for typing and formatting

* Improvements and cleanup

* Typing cleanup. Improve docstrings

* Proper code in docstrings

* Fix malformed code-block directive in docstring

* Remove dead links

* PR feedback

* Address PR feedback

* Address PR feedback

* Remove sl

* Update devui frontend

* More cleanup

* Fix uv lock

* Skip Py 3.14 tests as powerfx doesn't support it

* Fix mypy error

* Fix for tool calls

* Removed stale docstring

* Fix lint

* Standardize on .NET namespaces. Revert DevUI changes (bring in later)

* Implement remaining items for Python declarative support to match dotnet

* point URL to agent, not to agentcard (#3176)

* Python: [BREAKING]: Introducing Options as TypedDict and Generic (#3140)

* WIP typeddict for options

* updated all clients and ChatAgents

* updated everything

* added ADR

* fix mypy

* proper typevar imports

* fixed import

* fixed other imports

* slight update in the sample

* updated from feedback

* fixes

* fixed missing covariants and test fixes

* fixed typing

* updated anthropic thinking config

* ruff fixes

* fixed int tests

* fix tests and mypy

* updated integration tests

* updated docstring and test fix

* improved options handling in obser

* mypy fix

* updated a host of integration tests

* fix tests

* bedrock fix

* [BREAKING] Python: Refactor orchestrations (#3023)

* Group chat refactoring Part 1; Next: HIL and handoff

* Add agent approval flow; next samples

* WIP: samples

* WIP: HIL samples

* Group chat HIL working; next: handoff

* Fix group chat tool approval sample

* WIP: refactor handoff; next handoff handling

* Handoff done; next handoff samples and concurrent and sequential

* Handoff samples, concurrent, and sequential done; next Magentic

* WIP: magentic; next test with samples + HIL

* Magentic Working; next fix all samples and tests

* Fix handoff samples; next tests

* WIP: fixing tests; some orchestration as agent samples are failing

* Group chat unit tests done

* Handoff  unit tests done

* Remove old orchestration_request_info and fix related tests

* Magentic unit tests done

* Fix samples

* Fix test

* Fix test 2

* mypy

* Address comments

* Update readme

* Address comments

* Address comments 2

* Replace display name

* Python: ADR for create/get agent API (#2618)

* ADR for create/get agent API

* Updated ADR with implementation options

* Small updates

* Updated decision outcome section

* Updated broken links

* Small updates

* Fixed merge conflicts

* Small fix

* Updated decision outcome section

* Small fixes

* Updated provider naming based on client SDK

* Add ignored parameter for CodeQL in workflow (#3204)

* Implement IReadOnlyList on InMemoryChatMessageStore (#3205)

* .NET: Make ChatMessageStore and AIContextProvider context props settable (#3196)

* Make ChatMessageStore and AIContextProvider context props setable

* Add validation to preserve non-null requirement of certain properties.

* Fix broken tests.

* Python: Add dependencies param to ag-ui FastAPI endpoint (#3191)

* Add dependencies param to ag-ui FastAPI endpoint

* Address Copilot feedback

* renamed all (#3207)

* Python: ADR for simplified get response (#3098)

* ADR for simplified get response

* updated some language, added agent option and code comparison

* small update in sample

* added workflows and expanded some points

* changed decision and number

* updated with stream=False default

* .NET: [Breaking] Rename`AgentRunResponse` and `AgentRunResponseUpdate` classes (#3197)

* rename AgentRunResponse and AgentRunResponseUpdate classes - part1

* rename varialbles, parameters, methods and tests

* rollback unnecessary changes

* .NET: [Breaking] Rename AgentRunResponseEvent and AgentRunUpdateEvent classes (#3214)

* rename AgentRunResponseEvent and AgentRunUpdateEvent classes

* rollback unnecessary changes

* Python: Create/Get Agent API for Azure V2 (#3059)

* Added get_agent method to Azure AI V2

* Small fixes

* Small fix

* Removed AzureAIAgentProvider

* Added create_agent method

* Small fixes

* Fixed code interpreter tool mapping

* Added agent provider for V2 client

* Updated response format handling

* Added provider example

* Fixed errors

* Update python/samples/getting_started/agents/azure_ai/README.md

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

* Small fix

* Updates from merge

* Resolved comments

* Resolved comments

---------

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

* Python: Add more specific exceptions to Workflow (#3188)

* Add more specifc workflow exceptions

* Fix tests

* AI comments

* Misc

* Python: Added AzureAI sample for downloading code interpreter generated files (#3189)

* added azure ai code interpreter file download sample

* copilot fix suggestions

* function name fixes + readme update

* small fix

* update package versions (#3223)

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Python: fix(core): correct FunctionResultContent ordering in WorkflowAgent.merge_updates (#3168)

* fix(core): simplify FunctionResultContent ordering in WorkflowAgent.merge_updates

* improve comment

* Fix name

* fix(workflows): rename WorkflowOutputEvent.source_executor_id to executor_id for API consistency (#3166)

* Python: fix(ag-ui): add MCP tool support for AG-UI approval flows (#3212)

* add MCP tool support for AG-UI approval flows

* use attribute in place of property

* Python: Properly configure structured outputs based on new options dict (#3213)

* Properly configure structured outputs based on new options dict

* Fix mypy

* .NET: Merge AgentRunOptions.AdditionalProperties into ChatOptions.AdditionalProperties (#3184)

* Merge AgentRunOptions.AdditionalProperties into ChatOptions.AdditionalProperties

* Fix namespace and typo.

* .NET: Update Google.GenAI to 0.11.0 and remove polyfill implementations (#3232)

* Initial plan

* Update Google.GenAI to 0.11.0 and remove polyfill files

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* .NET: [BREAKING] Renamed CreateAIAgent/GetAIAgent to AsAIAgent (#3222)

* Renamed chat client extension method

* Additional renaming

* Updated documentation

* Fixed tests

* Small fix

* Small fix

* Updated DurableAIAgent and fixed integration tests (#3241)

* Python: Create/Get Agent API for Azure V1 (#3192)

* Added provider implementation for Azure AI V1

* Small fixes

* Fixed OpenAPI example

* Fixed local MCP example

* Fixed hosted MCP example

* Fixed file search sample

* Small fixes

* Resolved comments

* Doc updates

* Bump azure-core from 1.37.0 to 1.38.0 in /python (#3209)

Bumps [azure-core](https://github.com/Azure/azure-sdk-for-python) from 1.37.0 to 1.38.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-python/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-core_1.37.0...azure-core_1.38.0)

---
updated-dependencies:
- dependency-name: azure-core
  dependency-version: 1.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Python: Create/Get Agent API for OpenAI Assistants (#3208)

* Added provider implementation

* Added example with response format

* Small improvements

* Python: (AG-UI) Support service-managed thread on AG-UI  (#3136)

* added service thread support

* set service_thread_id to only supplied_thread_id

* uses raw_representation to extract the conversation_id

* removed accidental edit

* updated test to use raw_representation

* resolves copilot review feedback

* revert back StubAgent, since not used

* removed relative module import

* removed hasattr check per PR feedback

* Create/Get Agent API - fixes and example improvements (#3246)

* .NET Purview Middleware: Improve Background Job Runner Injection (#3256)

* Clean up background job dependency injection

* Fix xml documentation grammar

* Python: [BREAKING] Renamed create_agent to as_agent (#3249)

* Renamed create_agent to as_agent

* Override for as_agent

* Added override

* Python: Update package version (#3258)

* package version 260116

* removed name tags

* Python: Fixed Azure chat client for asynchronous filtering (#3260)

* Fixed Azure chat client for asynchronous filtering

* Updated test

* Python: Fixed use_agent_middleware calling private _normalize_messages (#3264)

* Fix use_agent_middleware calling private _normalize_messages

* Fixed A2A and Copilot Studio agent

* Python: Added rai_config to Azure AI agent creation (#3265)

* Add kwargs to create_agent method

* Added test for kwargs

* Addressed comment

* Added doc string

* Python: Filter conversation_id when passing kwargs to agent as tool (#3266)

* Filter conversation_id when passing kwargs to agent as tool

* Small fix

* Update python/samples/getting_started/agents/azure_ai/README.md

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

* Update python/samples/getting_started/agents/openai/openai_responses_client_with_agent_as_tool.py

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

* Update python/samples/getting_started/agents/azure_ai/azure_ai_with_agent_as_tool.py

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

---------

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

* Bump actions/setup-dotnet from 5.0.1 to 5.1.0 (#3273)

Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.0.1 to 5.1.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.0.1...v5.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: 5.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Update ignored checks in merge-gatekeeper workflow

* Python: [BREAKING] Make response_format validation errors visible to users (#3274)

* Make response_format validation errors visible to users

* Small fix

* Addressed comments

* Python: fix(declarative): Fix MCP tool connection not passed from YAML to Azure AI agent creation API (#3248)

* fix(declarative): Fix MCP tool connection not passed from YAML

* Add samples to README

* Fix mypy

* Fix mypy again

* Address PR comments

* fix #3171, ensure proper form rendering for int (#3201)

* Bump uv from 0.9.25 to 0.9.26 in /python (#3288)

Bumps [uv](https://github.com/astral-sh/uv) from 0.9.25 to 0.9.26.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.25...0.9.26)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.9.26
  dependency-type: direct:development
  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>

* Bump ruff from 0.14.11 to 0.14.13 in /python (#3287)

Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.11 to 0.14.13.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.11...0.14.13)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.14.13
  dependency-type: direct:development
  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>

* Bump tar from 7.4.3 to 7.5.3 in /python/packages/devui/frontend (#3267)

Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.3.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.3)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* .NET: Delete sync extension methods for agent (#3291)

* Delete sync extension methods for agent

* Fix comments and obsolete attribute

* Remove more sync methods.

* Fix naming and comments.

* Fix unit tests

* Python: Fix: Add system_instructions to ChatClient LLM span tracing (#3164)

* Fix: Add system_instructions to ChatClient LLM span tracing

- Add system_instructions parameter to _capture_messages() calls in
  _trace_get_response() and _trace_get_streaming_response()
- Extract instructions from chat_options in kwargs
- Add unit tests to verify system_instructions are captured correctly

When using ChatClient with ChatOptions.instructions, the OpenTelemetry
LLM span was missing system messages in gen_ai.input.messages and the
gen_ai.system_instructions attribute was not being set.

This fix aligns the ChatClient-level tracing with the Agent-level
tracing which already correctly passes system_instructions.

Fixes #3163

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add edge case tests for system_instructions

- Add test for empty string instructions (should not set attribute)
- Add test for list-type instructions (verify multiple items captured)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify: use options.get('instructions') directly instead of kwargs.get('chat_options')

Addresses reviewer feedback:
- Removed unnecessary chat_options variable from kwargs
- Directly access instructions from the options parameter
- Updated tests to use dict syntax for options (TypedDict convention)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* Improve PR number handling in workflow (#3302)

* Improve PR number handling in workflow

Refine PR number extraction and validation method.

* Update .github/workflows/python-test-coverage-report.yml

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

* Fix error message for invalid PR number

---------

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

* .NET: Update Microsoft.Extensions.AI.* packages to 10.2.0 (#3211)

* Initial plan

* Update Microsoft.Extensions.AI.* to 10.2.0 and fix timestamp behavior tests

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>

* .NET: Pass AdditionalProperties from parent to child when exposing an agent as a FunctionTool (#3219)

* Pass AdditionalProperties from parent to child when exposing an agent as a FunctionTool

* Rename variable to improve readability.

* Apply suggestions from code review

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

---------

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

* Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)

* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test

* .NET: Durable Agent samples and automated validation for non-Azure Functions (#3042)

* Durable Agent samples and automated validation for non-Azure Functions

* Update test projects

* fix file encoding

* Remove AgentThreadMetadata usage

* Absorb breaking change from #3152

* Absorb newer breaking changes (AgentRunResponse --> AgentResponse)

* Absorb more breaking changes (see #3222)

* Improve integration test reliability (isolated task hubs, etc.)

* Fix flakey streaming test

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* Python: fix(core): handle anyio cancel scope errors during MCP connection cleanup (#3277)

* fix(core): handle anyio cancel scope errors during MCP connection cleanup

* Address Copilot feedback

* Python: fix(ag-ui): properly handle json serialize with handoff workflows as agent (#3275)

* fix(ag-ui): properly handle json serialize with handoff workflows as agent

* Other improvements around handling non-serializable objects

* Bump tomli from 2.3.0 to 2.4.0 in /python (#3182)

Bumps [tomli](https://github.com/hukkin/tomli) from 2.3.0 to 2.4.0.
- [Changelog](https://github.com/hukkin/tomli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hukkin/tomli/compare/2.3.0...2.4.0)

---
updated-dependencies:
- dependency-name: tomli
  dependency-version: 2.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* .Net: Fix DebuggerDisplay attribute to reference existing property (#3326)

* Initial plan

* Fix DebuggerDisplay attribute to use Name instead of DisplayName

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* .NET: Add sample to show multiple AIContextProvider usage (#3284)

* Add sample to show multiple AIContextProvider usage

* Update comment.

* Update messaging in README.

* Address PR comments.

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .NET: Update Conversation Sample to use Conversation Id instead (#3180)

* Update Conversation Sample to use conversation Id instead

* Remove Run infix

* Remove the sync GetAIAgent from sample

* Python: Fix local MCP tools with `AzureAIProjectAgentProvider` (#3315)

* azureai v2 local mcp fix

* addressed copilot comments

* .NET: Improve readme for agents V2 (#3285)

* Improve readme for agents V2

* Architectural justification

* Update dotnet/samples/GettingStarted/FoundryAgents/README.md

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

---------

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

* Bump pyasn1 from 0.6.1 to 0.6.2 in /python (#3257)

Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.1 to 0.6.2.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.1...v0.6.2)

---
updated-dependencies:
- dependency-name: pyasn1
  dependency-version: 0.6.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* .NET: Fix DebuggerDisplay attribute in AIAgent.cs to reference existing properties (#2985)

* Initial plan

* Fix DebuggerDisplay attribute in AIAgent.cs to reference existing properties

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>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>

* Python: feat(anthropic): Add response_format support for structured outputs (#3301)

* fix(anthropic): Add response_format support for structured outputs

* only use from options

* use native way of response format

* ruff lint fix

* address comment; handle dict

* Updated package versions (#3335)

* Set min version of dependent azure-ai-projects to 2.0.0b3 (#3347)

* Adding feature collections ADR (#3332)

* .NET: [Breaking] Allow passing auth token credential to cosmosdb extensions (#3250)

* allow passing token credentials to cosmosdb extensions

* Update dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBWorkflowExtensions.cs

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

* Update dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBWorkflowExtensions.cs

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

* Update dotnet/src/Microsoft.Agents.AI.CosmosNoSql/CosmosDBChatExtensions.cs

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

---------

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

* fix: Subworkflows do not work well with HostAsAgent (#3240)

Subworkflows run into issues with Checkpointing and the Chat Protocol:

* The concurrency rework made subtle changes in behaviour that introduced a hang when using subworkflows with ChatProtocol and streaming execution.
* The ResetAsync() implementation in WorkflowHostExecutor was improperly resetting the joinContext - this was happening on restore checkpoint _after_ the join context was attached when
* Subworkflows cannot be used as the start node when hosted AsAgent due to inability to treat Catch-All as a Chat Protocol
* Subworkflow ownership issue when used in non-concurrent mode after finishing a run

Also fixes:
* When ChatMessages are output by executors that are not agents, there is no corresponding AgentResponseUpdate/AgentResponse event

Breaking Changes
* [BREAKING CHANGE] It is possible to provide the wrong RunId when resuming from CheckpointInfo (even though the data already exists on CheckpointInfo)

* Python: .NET: Executor source gen for workflow executor routing (#3131)

* Roslyn Source Generators for Workflow Executor Routing.

* Update dotnet/src/Microsoft.Agents.AI.Workflows.Generators/ExecutorRouteGenerator.cs

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

* WIP.

* All fixed up except dangling sends/yields attriutes, working on that next.

* Add protocol-only generation for SendsMessage/YieldsOutput attributes

* Ensuring collections that can change order are sorted to enable pipeline caching.

* Improvents per PR feedback.

---------

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

* .NET: Joslat fix sample issue (#3270)

* 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

* Fix in Sample

* update

---------

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>

* .NET: Improve unit test coverage for Microsoft.Agents.AI.OpenAI (#3349)

* Initial plan

* Add unit tests for Microsoft.Agents.AI.OpenAI to improve code coverage

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

* Address code review feedback: remove unused using directives

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

* Fix format issues: file encoding and remove unused using directives

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

* Fix redundant cast error by using named parameter

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

* Remove excessive inline comments per PR review feedback

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Revert to main

* Python: Fix: Verify types during checkpoint deserialization to prevent marker spoofing (#3243)

* Initial plan

* Add validation for reserved keywords in checkpoint encoding/decoding

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Refactor to eliminate duplicate code in model protocol detection

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Fix pyright type narrowing issue for dataclass check

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Add comprehensive unit tests for checkpoint encoding

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Remove serialization-time reserved keyword validation to fix failing tests

The serialization-time validation was too aggressive and blocked legitimate use cases
where encoded data was being re-encoded. Security is now enforced only at deserialization
time by validating that classes marked with DATACLASS_MARKER are actual dataclasses and
classes marked with MODEL_MARKER actually support the model protocol.

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Apply ruff formatting to checkpoint encoding file

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Changes before error encountered

Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>

* Revert "Changes before error encountered"

This reverts commit f515b880dc.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>

* Python: Fix azurefunctions MCP tool invocation to use correct agent  (#3339)

* MCP tool fix for azurefunctions

* Moving logic to check for thread id

* Adding ReflectExecutors method to Workflow. (#3389)

* Fix merge conflicts

* Python: [BREAKING] simplify ag-ui run logic, fix mcp bugs, fix anthropic client issues in ag-ui (#3322)

* Refactor ag-ui to simplify flow

* Refactoring

* Fix backend tool

* Update tests

* Improvements

* Fix mypy

* Fixes

* Fix json serialize errors

* Python: fix(core): filter out internal args when passing kwargs to MCP tools (#3292)

* fix(core): filter conversation_id when passing kwargs to MCP tools

* Filter out options too

* Fix uv.lock conflict

* Python: Added tests for OpenAI content types + Unit test improvement (#3259)

* added tests for content types+ unit test improvement

* small fixes

* small fix

* Python: Prefer runtime `kwargs` for `conversation_id` in OpenAI Responses client (#3312)

* prefer kwargs conversation_id over options

* addressed comments

* Python: Azure AI mapping HostedImageGenerationTool to ImageGenTool (#3263)

* azureai image gen sample fix

* mypy fixes

* addressed comments + mapping updates

* image model fix

* content type fix

* Python: add(azure-ai): support reasoning config for AzureAIClient (#3403)

* add(azure-ai): support reasoning config for AzureAIClient

* Update sample

* Merge main

* improvements

* improve sample

* .NET: Allow overriding the ChatMessageStore to be used per agent run. (#3330)

* Allow overriding the ChatMessageStore to be used per agent run.

* Fix typos

* Fix Add and add TryAdd, Contains and Remove

* Update instructions to require automatically building and formatting (#3412)

* .NET: Rename ChatMessageStore to ChatHistoryProvider (#3375)

* Rename ChatMessageStore to ChatHistoryProvider

* Fix merge issue

* Fixed PR comments

* Fix tests after property rename

* Add unit tests and fix merge issues

* Fix encoding

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Dina Suehiro Jones <dina.s.jones@intel.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: Kurt <65111699+q33566@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Jose Luis Latorre Millas <joslat@gmail.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Richard Ortega <richardjortega@gmail.com>
Co-authored-by: 刘邦学AI <lbbniu@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Nico Möller <nkm-moeller@mail.de>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
Co-authored-by: Phillip Hoff <phillip.hoff@gmail.com>
Co-authored-by: Ege Ozan Özyedek <36128615+egeozanozyedek@users.noreply.github.com>
Co-authored-by: samueljohnsiby <66901393+samueljohnsiby@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
Co-authored-by: Hao Luo <338265+howlowck@users.noreply.github.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Jacob Viau <javia@microsoft.com>
Co-authored-by: SuperKenVery <39673849+SuperKenVery@users.noreply.github.com>
Co-authored-by: Sunil Dutta <dutta.2003@gmail.com>
Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
Co-authored-by: Syrine Chelly <62653967+SyChell@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: takanori-terai <123897708+takanori-terai@users.noreply.github.com>
Co-authored-by: claude89757 <138977524+claude89757@users.noreply.github.com>
Co-authored-by: Gavin Aguiar <80794152+gavin-aguiar@users.noreply.github.com>
Co-authored-by: Sukeesh <vsukeeshbabu@gmail.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
Co-authored-by: Ao Chen <chenao3220@gmail.com>
Co-authored-by: Dina Suehiro Jones <dina.s.jones@intel.com>
Co-authored-by: eoindoherty1 <eoindoherty@microsoft.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Darren Cohen <39422044+dargilco@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Co-authored-by: Shyju Krishnankutty <connectshyju@gmail.com>
2026-01-23 10:31:54 -08:00
Laveesh RohraandGitHub ff839435a2 Fix imports for durabletask (#3345) 2026-01-21 14:38:37 -08:00
Laveesh RohraandGitHub e032133748 Python: Add integration tests for durabletask package (#3317)
* Add integration tests

* Fix flaky test

* Fix env viz

* Fix tests and address feedback
2026-01-21 14:31:24 -08:00
+37 cd77193742 Python: Merge main into feature-durabletask-python branch (#3261)
* 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

* 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

* Capture file IDs from code interpreter in streaming responses (#2741)

* .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)

* prevent nulls in AIAgent property

* address feedback

* code ql sm04598 (#2723)

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .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>

* 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>

* 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>

* 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>

* 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

* Python: Updated package versions (#2784)

* Updated package versions

* Small fix

* 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>

* .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>

* 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

* Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774)

* 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/

* Python: Remove warnings from workflow builder on not using factories (#2808)

* Revert concurrent

* Fix comments

* Python: Filter framework kwargs from MCP tool invocations (#2870)

* Filter framework kwargs from MCP tool invocations

* Fixes

* 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

* 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>

* 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

* 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

* 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

* .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>

* 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>

* 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>

* 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>

* 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>

* Skip failing IT (#2904)

* .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

* .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

* 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

* Updated package versions (#2913)

* .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.

* 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.

* [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

* 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

* Fix Pydantic error when using Literal type for tool params (#2893)

* Updated Ollama package version (#2920)

* Python: Azure AI Agent with Bing Grounding Citations Sample (#2892)

* bing grounding sample with citations

* small fix

* fix

* .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>

* Added additional arguments for Azure AI agent (#2922)

* 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

* Pass kwargs into subworkflows (#2923)

* Python: Move ollama samples to samples getting started dir (#2921)

* Move ollama samples to samples getting started dir

* Address feedback

* 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>

* .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>

* 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

* Python: Workflow add option to visualize internal executors (#2917)

* Workflow add option to visualize internal executors

* Address Copilot comments

* 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

* 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

* .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>

* Python: Add Azure Managed Redis Support with Credential Provider (#2887)

* azure redis support

* small fixes

* azure managed redis sample

* fixes

* 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>

* 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>

* 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>

* Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946)

* Fix kwargs propagation through workflow.as_agent()

* Fix WorkflowAgent to respect AgentExecutor output_response setting

* .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

* 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

* 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

* Updated package versions (#2978)

* Python: Added GitHub MCP sample with PAT (#2967)

* added github mcp sample with PAT

* addressed copilot fixes

* env fix

* 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

* 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

* 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>

* .NET: [Durable Agents] Reliable streaming sample (#2942)

* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback

* 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>

* Python: latency improvements (#3014)

* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement

* Python: Updated package versions (#3024)

* Updated package versions

* Updated changelog

* Python: add powerfx safe mode (#3028)

* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset

* .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>

* add issue template and additional labeling (#3006)

* fix and extra int test (#3037)

* .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>

* [BREAKING] Remove unused AgentThreadMetadata (#3067)

* Remove unused AgentThreadMetadata

* Update DurableTask Changelog

* 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

* Fix: Update OTLP exporter protocol conditions (#3070)

* Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)

* Fix ExecutorInvokedEvent.data mutation bug

* Fix bug related to not yielding output type

* .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>

* Fix broken strands urls. (#3102)

* Fix broken strands urls.

* Fix typos

* .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>

* fix: tool_choice parameter not being honored when passed to agent.run() (#3095)

* sharepoint sample fix (#3108)

* Bump versions to 1.0.0b260106 for a release. Update CHANGELOG.md (#3109)

* Bump Bedrock version to latest (#3110)

* Python: Fix MCP tool result serialization for list[TextContent] (#2523)

* Fix MCP tool result serialization for list[TextContent]

When MCP tools return results containing list[TextContent], they were
incorrectly serialized to object repr strings like:
'[<agent_framework._types.TextContent object at 0x...>]'

This fix properly extracts text content from list items by:
1. Checking if items have a 'text' attribute (TextContent)
2. Using model_dump() for items that support it
3. Falling back to str() for other types
4. Joining single items as plain text, multiple items as JSON array

Fixes #2509

* Address PR review feedback for MCP tool result serialization

- Extract serialize_content_result() to shared _utils.py
- Fix logic: use texts[0] instead of join for single item
- Add type annotation: texts: list[str] = []
- Return empty string for empty list instead of '[]'
- Move import json to file top level
- Add comprehensive unit tests for serialization

* Address PR review feedback: fix type checking and double serialization

- Add isinstance(item.text, str) check to ensure text attribute is a string
- Fix double-serialization issue by keeping model_dump results as dicts
  until final json.dumps (removes escaped JSON strings in arrays)
- Improve docstring with detailed return value documentation
- Add test for non-string text attribute handling
- Add tests for list type tool results in _events.py path

* Simplify PR: minimal changes to fix MCP tool result serialization

Addresses reviewer feedback about excessive refactoring:
- Reset _events.py to original structure
- Only add import and use serialize_content_result in one location
- All review comments addressed in serialize_content_result():
  - Added isinstance(item.text, str) check
  - Use model_dump(mode="json") to avoid double-serialization
  - Improved docstring with explicit return value documentation
  - Empty list returns "" instead of "[]"

* Refactor: Move MCP TextContent serialization to core prepare_function_call_results

Per reviewer feedback, moved the TextContent serialization logic from
ag-ui's serialize_content_result to the core package's
prepare_function_call_results function.

Changes:
- Added handling for objects with 'text' attribute (like MCP TextContent)
  in _prepare_function_call_results_as_dumpable
- Removed serialize_content_result from ag-ui/_utils.py
- Updated _events.py and _message_adapters.py to use
  prepare_function_call_results from core package
- Updated tests to match the core function's behavior

* Fix failing tests for prepare_function_call_results behavior

- test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None)
- test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class

* Fix B903 linter error: Convert MockTextContent to dataclass

The ruff linter was reporting B903 (class could be dataclass or namedtuple)
for the MockTextContent test helper classes. This commit converts them to
dataclasses to satisfy the linter check.

* Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)

* Improve DevUI, add Context Inspector view as new tab under traces

* fix mypy errors

* fix: Handle stale MCP connections in DevUI executor

MCP tools can become stale when HTTP streaming responses end - the underlying
stdio streams close but `is_connected` remains True. This causes subsequent
requests to fail with `ClosedResourceError`.

Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before
agent execution. This is a workaround for an upstream Agent Framework issue
where connection state isn't properly tracked.

Fixes MCP tools failing on second HTTP request in DevUI.

fixes  #1476 #1515 #2865

* fix #1572 report import dependency errors more clearly

* Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode?

* remove unused dead code

* improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly

* update ui build

* update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes

* .NET: Seal factory contexts and add non JSO deserialize overloads (#3066)

* Seal factory contexts and add non JSO deserialize overloads

* Apply suggestions from code review

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

---------

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

* Enable blank issues in issue template configuration

Need to re-enable creating blank issues

* updated templates (#3106)

* updated templates

* enabled blank and fixed triage

* made language optional and moved to the bottom for features

* Python: Streaming sample for azurefunctions (#3057)

* Streaming sample for azurefunctions

* Fixed links and sample name

* Addressed feedback

* Addressed feedback

* Fixed integration tests

* Updated test

* Python: fix(azure-ai): Fix response_format handling for structured outputs (#3114)

* fix(azure-ai): read response_format from chat_options instead of run_options

* refactor: use explicit None checks for response_format

* Fix mypy error

* Mypy fix

* Python: Bump python version to 1.0.0b260107 for a release (#3128)

* Bump python version to 1.0.0b260107 for a release

* Update changelog

* Make A2AAgent public, so that it's concrete implementation methods can be used. (#3119)

* .NET: Map additional props <-> A2A metadata (#3137)

* map additional props from agent run options to a2a request metadata

* small touches

* add unit tests for new extension methods

* Sort using

* add unit test

* add additiona unit tests

* special case json element to avoid unnecessary serialization

* Python: Fix Anthropic streaming response bugs (#3141)

* test commit identity

* fix(anthropic): fix raw_representation and finish_reason in streaming

* lint fix

* Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.5 to 4.0.5.1 (#2994)

---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.5.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* Bump Anthropic from 12.0.0 to 12.0.1 (#2993)

---
updated-dependencies:
- dependency-name: Anthropic
  dependency-version: 12.0.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* .NET: [Breaking] Prevent loss of input messages & streamed updates when resuming streaming (#2748)

* save input messages and stream updates to the continuation token to be able to use them in the last successful stream resumption call.

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* fix typo

* init continuation token from chat response

* remove unnecessary types for source generation

* remove check for continuation token passed at initial run

* remove check for continuation token pass at initial run

* centralize continuation token parsing

* update xml comments

* use readonly collection instead of enumerable

---------

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

* .NET: fix: Expose WorkflowErrorEvent as ErrorContent (#2762)

* fix: Expose WorkflowErrorEvent as ErrorContent

When hosted using .AsAgent(), Workflows were not exposing inner errors coming as Exceptions (through the WorkflowErrorEvent)

The fix is to convert their message to an ErrorContent on the way out, rather than rely on the default "empty update" to collect the raw event.

* feat: Add a way to show/suppress exception information

* Bump Microsoft.Agents.AI.Workflows from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1 (#2997)

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.Workflows
  dependency-version: 1.0.0-preview.251219.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* .NET: Add Run overloads to expose ChatClientAgentRunOptions in IntelliSense (#3115)

* Initial plan

* Add ChatClientAgentExtensions for improved discoverability of ChatClientAgentRunOptions

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

* Address code review feedback - use collection expression syntax

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

* Apply suggestion from @westey-m

* Fix issues with Copilot implementation

* Add additional tests for structured output overloads.

---------

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>

* Python: Add tool call/result content types and update connectors and samples (#2971)

* Add new AI content types and image tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add Python content types for tool calls/results and image generation tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address review feedback for tool content and samples

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Tighten image generation typing and sample tools list

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Align image generation output typing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Handle MCP naming, image options mapping, and connector tool content

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Allow MCP call in function approval request

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Remove raw image_generation tool remapping

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Restore Anthropic tool_use to function calls unless code execution

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix lint issues for hosted file docstring and MCP parsing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Import ChatResponse types in Anthropic client

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix Anthropics citation type imports and MCP typing for handoff/tools

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Skip lightning tests without agentlightning and fix function call import

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* fix lint on lab package

* rebuilt anthropic parsing

* redid anthropic parsing

* typo

* updated parsing and added missing docstrings

* fix tests

* mypy fixes

* second mypy fix

* add new class to other samples

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>

* Bump Google.GenAI from 0.6.0 to 0.9.0 (#2995)

---
updated-dependencies:
- dependency-name: Google.GenAI
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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>

* Bump js-yaml from 4.1.0 to 4.1.1 in /python/packages/devui/frontend (#3123)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Updated package versions (#3144)

* .NET: Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI (#2996)

* Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI

Bumps Microsoft.Agents.AI.OpenAI from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1
Bumps Microsoft.Extensions.AI.OpenAI from 10.1.0-preview.1.25608.1 to 10.1.1-preview.1.25612.2

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed samples

---------

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>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Python: fix(ag-ui): Execute tools with approval_mode, fix shared state, code cleanup  (#3079)

* fix(ag-ui): execute tools after approval in human-in-the-loop flow

* Fix shared state bug

* Bug fix finalized

* Refactoring to clean up code

* Code cleanup

* More fixes

* More code cleanup

* Add version detection in __init__.py to ruff ignore list

* Track agent name with updates for workflow agent (#3146)

* Python: Fix AzureAIClient tool call bug for AG-UI use (#3148)

* Fiz AzureAIClient tool call bug

* Address copilot feedback

* Python: multiple bug fixes (#3150)

* fix Python: kwargs are not passed to _prepare_thread_and_messages in ChatAgent.run
Fixes #3118

* fix Python: [Bug]: model_id versus model_deployment_name is confusing in Azure AI Agents
Fixes #3147

* add types

* fixed type and docstring

* fix(anthropic): fix duplicate ToolCallStartEvent in streaming tool calls (#3051)

When processing `input_json_delta` events, the Anthropic client was
passing the tool name from the previous `tool_use` event. This caused
ag-ui's `_handle_function_call_content` to emit a `ToolCallStartEvent`
for every streaming chunk (since it triggers on `if content.name:`).

This fix changes the behavior to pass an empty string for `name` in
`input_json_delta` events, matching OpenAI's behavior where streaming
argument chunks have `name=""`. The initial `tool_use` event still
provides the tool name, so only one `ToolCallStartEvent` is emitted.

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

* .NET: [BREAKING] Change GetNewThread and DeserializeThread to async (#3152)

* Change GetNewThread and DeserializeThread plus ChatMessageStore and AIContextProvider Factories to async

* Merge fixes

* Fix Ollama model env var in documentation (#3156)

Signed-off-by: Dina Suehiro Jones <dina.s.jones@intel.com>

* Python: Add Pydantic request model and OpenAPI tags support to AG-UI FastAPI endpoint (#2522)

* feat(ag-ui): Add Pydantic request model and OpenAPI tags support

- Add AGUIRequest Pydantic model in _types.py with field descriptions
- Update add_agent_framework_fastapi_endpoint() to accept tags parameter
- Use AGUIRequest model for automatic validation and OpenAPI schema generation
- Export AGUIRequest and DEFAULT_TAGS in __init__.py
- Update test_endpoint.py to expect 422 for invalid requests
- Add tests for OpenAPI schema, default tags, custom tags, and validation

Benefits:
- Better API documentation with complete request schema in Swagger UI
- Automatic request validation with Pydantic
- Organized endpoints under 'AG-UI' tag instead of 'default'
- Improved developer experience and type safety

Fixes #<issue-number>

* test(ag-ui): Add test for internal error handling to achieve 100% coverage

- Add test_endpoint_internal_error_handling() to cover exception handling code
- Mock copy.deepcopy to simulate internal error during default_state processing
- Add type: ignore for FastAPI tags parameter (known pyright compatibility issue)
- Achieves 100% test coverage for _endpoint.py (previously missing lines 103-105)

* .NET: Improve resolving `AITool` from DI (#3175)

* remove localagenttoolregistry

* also give the factory method API

* Python: Fix MCPStreamableHTTPTool to use new streamable_http_client API (#3088)

* Fix MCPStreamableHTTPTool to use new streamable_http_client API with proper httpx client cleanup

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Update docstring to reflect new streamable_http_client API usage

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Refactor MCPStreamableHTTPTool to accept optional http_client parameter and delegate client creation to streamable_http_client

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Update mcp package minimum version to 1.24.0 for streamable_http_client API support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix critical bugs: apply headers/timeout/sse_read_timeout when creating httpx client, add version constraint <2, and properly manage client lifecycle

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Simplify implementation: remove headers/timeout/sse_read_timeout params, remove kwargs, remove close() override per feedback

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add back **kwargs parameter for backward compatibility (accepted but not used)

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Remove unused httpx import from test file

Note: The uv.lock file needs to be updated with 'uv sync' to reflect the mcp version constraint change (>=1.24.0,<2)

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* cicd fixes

* udpated samples with headers examples

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>

* azureai direct a2a endpoint support (#3127)

* Python: [BREAKING]: removed display_name, renamed context_providers, middleware and AggregateContextProvider (#3139)

* removed display_name, renamed context_providers, middleware and AggregateContextProvider

* fixes

* fixed test

* testfix

* removed mistakenly put back test

* updated new test

* rename middlewares to middleware

* middleware fixes

* Python: MCP Improvements: improved connection loss behavior, pagination for loading and a param to control representation (#3154)

* pagination support (#2848) added a parse_tool_result param and connection loss (#2884)

* fix #3153

* improved connection handling

* improved logic

* Python: Add declarative workflow runtime (#2815)

* Further support for declarative python workflows

* Add tests. Clean up for typing and formatting

* Improvements and cleanup

* Typing cleanup. Improve docstrings

* Proper code in docstrings

* Fix malformed code-block directive in docstring

* Remove dead links

* PR feedback

* Address PR feedback

* Address PR feedback

* Remove sl

* Update devui frontend

* More cleanup

* Fix uv lock

* Skip Py 3.14 tests as powerfx doesn't support it

* Fix mypy error

* Fix for tool calls

* Removed stale docstring

* Fix lint

* Standardize on .NET namespaces. Revert DevUI changes (bring in later)

* Implement remaining items for Python declarative support to match dotnet

* point URL to agent, not to agentcard (#3176)

* Python: [BREAKING]: Introducing Options as TypedDict and Generic (#3140)

* WIP typeddict for options

* updated all clients and ChatAgents

* updated everything

* added ADR

* fix mypy

* proper typevar imports

* fixed import

* fixed other imports

* slight update in the sample

* updated from feedback

* fixes

* fixed missing covariants and test fixes

* fixed typing

* updated anthropic thinking config

* ruff fixes

* fixed int tests

* fix tests and mypy

* updated integration tests

* updated docstring and test fix

* improved options handling in obser

* mypy fix

* updated a host of integration tests

* fix tests

* bedrock fix

* [BREAKING] Python: Refactor orchestrations (#3023)

* Group chat refactoring Part 1; Next: HIL and handoff

* Add agent approval flow; next samples

* WIP: samples

* WIP: HIL samples

* Group chat HIL working; next: handoff

* Fix group chat tool approval sample

* WIP: refactor handoff; next handoff handling

* Handoff done; next handoff samples and concurrent and sequential

* Handoff samples, concurrent, and sequential done; next Magentic

* WIP: magentic; next test with samples + HIL

* Magentic Working; next fix all samples and tests

* Fix handoff samples; next tests

* WIP: fixing tests; some orchestration as agent samples are failing

* Group chat unit tests done

* Handoff  unit tests done

* Remove old orchestration_request_info and fix related tests

* Magentic unit tests done

* Fix samples

* Fix test

* Fix test 2

* mypy

* Address comments

* Update readme

* Address comments

* Address comments 2

* Replace display name

* Python: ADR for create/get agent API (#2618)

* ADR for create/get agent API

* Updated ADR with implementation options

* Small updates

* Updated decision outcome section

* Updated broken links

* Small updates

* Fixed merge conflicts

* Small fix

* Updated decision outcome section

* Small fixes

* Updated provider naming based on client SDK

* Add ignored parameter for CodeQL in workflow (#3204)

* Implement IReadOnlyList on InMemoryChatMessageStore (#3205)

* .NET: Make ChatMessageStore and AIContextProvider context props settable (#3196)

* Make ChatMessageStore and AIContextProvider context props setable

* Add validation to preserve non-null requirement of certain properties.

* Fix broken tests.

* Python: Add dependencies param to ag-ui FastAPI endpoint (#3191)

* Add dependencies param to ag-ui FastAPI endpoint

* Address Copilot feedback

* renamed all (#3207)

* Python: ADR for simplified get response (#3098)

* ADR for simplified get response

* updated some language, added agent option and code comparison

* small update in sample

* added workflows and expanded some points

* changed decision and number

* updated with stream=False default

* .NET: [Breaking] Rename`AgentRunResponse` and `AgentRunResponseUpdate` classes (#3197)

* rename AgentRunResponse and AgentRunResponseUpdate classes - part1

* rename varialbles, parameters, methods and tests

* rollback unnecessary changes

* .NET: [Breaking] Rename AgentRunResponseEvent and AgentRunUpdateEvent classes (#3214)

* rename AgentRunResponseEvent and AgentRunUpdateEvent classes

* rollback unnecessary changes

* Python: Create/Get Agent API for Azure V2 (#3059)

* Added get_agent method to Azure AI V2

* Small fixes

* Small fix

* Removed AzureAIAgentProvider

* Added create_agent method

* Small fixes

* Fixed code interpreter tool mapping

* Added agent provider for V2 client

* Updated response format handling

* Added provider example

* Fixed errors

* Update python/samples/getting_started/agents/azure_ai/README.md

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

* Small fix

* Updates from merge

* Resolved comments

* Resolved comments

---------

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

* Python: Add more specific exceptions to Workflow (#3188)

* Add more specifc workflow exceptions

* Fix tests

* AI comments

* Misc

* Python: Added AzureAI sample for downloading code interpreter generated files (#3189)

* added azure ai code interpreter file download sample

* copilot fix suggestions

* function name fixes + readme update

* small fix

* update package versions (#3223)

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Python: fix(core): correct FunctionResultContent ordering in WorkflowAgent.merge_updates (#3168)

* fix(core): simplify FunctionResultContent ordering in WorkflowAgent.merge_updates

* improve comment

* Fix name

* fix(workflows): rename WorkflowOutputEvent.source_executor_id to executor_id for API consistency (#3166)

* Python: fix(ag-ui): add MCP tool support for AG-UI approval flows (#3212)

* add MCP tool support for AG-UI approval flows

* use attribute in place of property

* Python: Properly configure structured outputs based on new options dict (#3213)

* Properly configure structured outputs based on new options dict

* Fix mypy

* .NET: Merge AgentRunOptions.AdditionalProperties into ChatOptions.AdditionalProperties (#3184)

* Merge AgentRunOptions.AdditionalProperties into ChatOptions.AdditionalProperties

* Fix namespace and typo.

* .NET: Update Google.GenAI to 0.11.0 and remove polyfill implementations (#3232)

* Initial plan

* Update Google.GenAI to 0.11.0 and remove polyfill files

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* .NET: [BREAKING] Renamed CreateAIAgent/GetAIAgent to AsAIAgent (#3222)

* Renamed chat client extension method

* Additional renaming

* Updated documentation

* Fixed tests

* Small fix

* Small fix

* Updated DurableAIAgent and fixed integration tests (#3241)

* Python: Create/Get Agent API for Azure V1 (#3192)

* Added provider implementation for Azure AI V1

* Small fixes

* Fixed OpenAPI example

* Fixed local MCP example

* Fixed hosted MCP example

* Fixed file search sample

* Small fixes

* Resolved comments

* Doc updates

* Bump azure-core from 1.37.0 to 1.38.0 in /python (#3209)

Bumps [azure-core](https://github.com/Azure/azure-sdk-for-python) from 1.37.0 to 1.38.0.
- [Release notes](https://github.com/Azure/azure-sdk-for-python/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-python/compare/azure-core_1.37.0...azure-core_1.38.0)

---
updated-dependencies:
- dependency-name: azure-core
  dependency-version: 1.38.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Python: Create/Get Agent API for OpenAI Assistants (#3208)

* Added provider implementation

* Added example with response format

* Small improvements

* Python: (AG-UI) Support service-managed thread on AG-UI  (#3136)

* added service thread support

* set service_thread_id to only supplied_thread_id

* uses raw_representation to extract the conversation_id

* removed accidental edit

* updated test to use raw_representation

* resolves copilot review feedback

* revert back StubAgent, since not used

* removed relative module import

* removed hasattr check per PR feedback

* Create/Get Agent API - fixes and example improvements (#3246)

* Fix merge conflicts

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Dina Suehiro Jones <dina.s.jones@intel.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: Kurt <65111699+q33566@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Jose Luis Latorre Millas <joslat@gmail.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Richard Ortega <richardjortega@gmail.com>
Co-authored-by: 刘邦学AI <lbbniu@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Nico Möller <nkm-moeller@mail.de>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
Co-authored-by: Phillip Hoff <phillip.hoff@gmail.com>
Co-authored-by: Ege Ozan Özyedek <36128615+egeozanozyedek@users.noreply.github.com>
Co-authored-by: samueljohnsiby <66901393+samueljohnsiby@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
Co-authored-by: Hao Luo <338265+howlowck@users.noreply.github.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Jacob Viau <javia@microsoft.com>
Co-authored-by: SuperKenVery <39673849+SuperKenVery@users.noreply.github.com>
Co-authored-by: Sunil Dutta <dutta.2003@gmail.com>
Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
Co-authored-by: Syrine Chelly <62653967+SyChell@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: takanori-terai <123897708+takanori-terai@users.noreply.github.com>
Co-authored-by: claude89757 <138977524+claude89757@users.noreply.github.com>
Co-authored-by: Gavin Aguiar <80794152+gavin-aguiar@users.noreply.github.com>
Co-authored-by: Sukeesh <vsukeeshbabu@gmail.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
Co-authored-by: Ao Chen <chenao3220@gmail.com>
Co-authored-by: Dina Suehiro Jones <dina.s.jones@intel.com>
2026-01-16 16:59:49 -08:00
Laveesh RohraandGitHub 3df916064c Python: Add Durabletask samples and minor fixes (#3157)
* Add samples and minor fixes

* Add redis sample and wait-for-completion

* Add wait-for-completion support

* ADd missing docs
2026-01-14 10:56:11 -08:00
+35 1e36ba33c4 .NET: Python: Merge main into feature-durabletask-python branch (#3160)
* 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

* 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

* Capture file IDs from code interpreter in streaming responses (#2741)

* .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)

* prevent nulls in AIAgent property

* address feedback

* code ql sm04598 (#2723)

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .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>

* 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>

* 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>

* 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>

* 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

* Python: Updated package versions (#2784)

* Updated package versions

* Small fix

* 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>

* .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>

* 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

* Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774)

* 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/

* Python: Remove warnings from workflow builder on not using factories (#2808)

* Revert concurrent

* Fix comments

* Python: Filter framework kwargs from MCP tool invocations (#2870)

* Filter framework kwargs from MCP tool invocations

* Fixes

* 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

* 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>

* 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

* 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

* 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

* .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>

* 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>

* 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>

* 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>

* 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>

* Skip failing IT (#2904)

* .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

* .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

* 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

* Updated package versions (#2913)

* .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.

* 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.

* [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

* 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

* Fix Pydantic error when using Literal type for tool params (#2893)

* Updated Ollama package version (#2920)

* Python: Azure AI Agent with Bing Grounding Citations Sample (#2892)

* bing grounding sample with citations

* small fix

* fix

* .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>

* Added additional arguments for Azure AI agent (#2922)

* 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

* Pass kwargs into subworkflows (#2923)

* Python: Move ollama samples to samples getting started dir (#2921)

* Move ollama samples to samples getting started dir

* Address feedback

* 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>

* .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>

* 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

* Python: Workflow add option to visualize internal executors (#2917)

* Workflow add option to visualize internal executors

* Address Copilot comments

* 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

* 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

* .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>

* Python: Add Azure Managed Redis Support with Credential Provider (#2887)

* azure redis support

* small fixes

* azure managed redis sample

* fixes

* 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>

* 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>

* 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>

* Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946)

* Fix kwargs propagation through workflow.as_agent()

* Fix WorkflowAgent to respect AgentExecutor output_response setting

* .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

* 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

* 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

* Updated package versions (#2978)

* Python: Added GitHub MCP sample with PAT (#2967)

* added github mcp sample with PAT

* addressed copilot fixes

* env fix

* 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

* 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

* 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>

* .NET: [Durable Agents] Reliable streaming sample (#2942)

* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback

* 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>

* Python: latency improvements (#3014)

* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement

* Python: Updated package versions (#3024)

* Updated package versions

* Updated changelog

* Python: add powerfx safe mode (#3028)

* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset

* .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>

* add issue template and additional labeling (#3006)

* fix and extra int test (#3037)

* .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>

* [BREAKING] Remove unused AgentThreadMetadata (#3067)

* Remove unused AgentThreadMetadata

* Update DurableTask Changelog

* 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

* Fix: Update OTLP exporter protocol conditions (#3070)

* Python: Fix ExecutorInvokedEvent and ExecutorCompletedEvent observability data (#3090)

* Fix ExecutorInvokedEvent.data mutation bug

* Fix bug related to not yielding output type

* .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>

* Fix broken strands urls. (#3102)

* Fix broken strands urls.

* Fix typos

* .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>

* fix: tool_choice parameter not being honored when passed to agent.run() (#3095)

* sharepoint sample fix (#3108)

* Bump versions to 1.0.0b260106 for a release. Update CHANGELOG.md (#3109)

* Bump Bedrock version to latest (#3110)

* Python: Fix MCP tool result serialization for list[TextContent] (#2523)

* Fix MCP tool result serialization for list[TextContent]

When MCP tools return results containing list[TextContent], they were
incorrectly serialized to object repr strings like:
'[<agent_framework._types.TextContent object at 0x...>]'

This fix properly extracts text content from list items by:
1. Checking if items have a 'text' attribute (TextContent)
2. Using model_dump() for items that support it
3. Falling back to str() for other types
4. Joining single items as plain text, multiple items as JSON array

Fixes #2509

* Address PR review feedback for MCP tool result serialization

- Extract serialize_content_result() to shared _utils.py
- Fix logic: use texts[0] instead of join for single item
- Add type annotation: texts: list[str] = []
- Return empty string for empty list instead of '[]'
- Move import json to file top level
- Add comprehensive unit tests for serialization

* Address PR review feedback: fix type checking and double serialization

- Add isinstance(item.text, str) check to ensure text attribute is a string
- Fix double-serialization issue by keeping model_dump results as dicts
  until final json.dumps (removes escaped JSON strings in arrays)
- Improve docstring with detailed return value documentation
- Add test for non-string text attribute handling
- Add tests for list type tool results in _events.py path

* Simplify PR: minimal changes to fix MCP tool result serialization

Addresses reviewer feedback about excessive refactoring:
- Reset _events.py to original structure
- Only add import and use serialize_content_result in one location
- All review comments addressed in serialize_content_result():
  - Added isinstance(item.text, str) check
  - Use model_dump(mode="json") to avoid double-serialization
  - Improved docstring with explicit return value documentation
  - Empty list returns "" instead of "[]"

* Refactor: Move MCP TextContent serialization to core prepare_function_call_results

Per reviewer feedback, moved the TextContent serialization logic from
ag-ui's serialize_content_result to the core package's
prepare_function_call_results function.

Changes:
- Added handling for objects with 'text' attribute (like MCP TextContent)
  in _prepare_function_call_results_as_dumpable
- Removed serialize_content_result from ag-ui/_utils.py
- Updated _events.py and _message_adapters.py to use
  prepare_function_call_results from core package
- Updated tests to match the core function's behavior

* Fix failing tests for prepare_function_call_results behavior

- test_tool_result_with_none: Update expected value to 'null' (JSON serialization of None)
- test_tool_result_with_model_dump_objects: Use Pydantic BaseModel instead of plain class

* Fix B903 linter error: Convert MockTextContent to dataclass

The ruff linter was reporting B903 (class could be dataclass or namedtuple)
for the MockTextContent test helper classes. This commit converts them to
dataclasses to satisfy the linter check.

* Python: Improve DevUI, add Context Inspector view as new tab under traces (#2742)

* Improve DevUI, add Context Inspector view as new tab under traces

* fix mypy errors

* fix: Handle stale MCP connections in DevUI executor

MCP tools can become stale when HTTP streaming responses end - the underlying
stdio streams close but `is_connected` remains True. This causes subsequent
requests to fail with `ClosedResourceError`.

Add `_ensure_mcp_connections()` to detect and reconnect stale MCP tools before
agent execution. This is a workaround for an upstream Agent Framework issue
where connection state isn't properly tracked.

Fixes MCP tools failing on second HTTP request in DevUI.

fixes  #1476 #1515 #2865

* fix #1572 report import dependency errors more clearly

* Ensure there is streaming toggle where users can select streaming vs non streaming mode in devui . Fixes .NET: [Python] DevUI tool call rendering in non-streaming mode?

* remove unused dead code

* improve ux - workflows with agents show a chat component in execution timelien, also ensure magentic final output shows correctly

* update ui build

* update devui to use instrumentation instead of tracing, other instrumentation and type/instance check fixes

* .NET: Seal factory contexts and add non JSO deserialize overloads (#3066)

* Seal factory contexts and add non JSO deserialize overloads

* Apply suggestions from code review

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

---------

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

* Enable blank issues in issue template configuration

Need to re-enable creating blank issues

* updated templates (#3106)

* updated templates

* enabled blank and fixed triage

* made language optional and moved to the bottom for features

* Python: Streaming sample for azurefunctions (#3057)

* Streaming sample for azurefunctions

* Fixed links and sample name

* Addressed feedback

* Addressed feedback

* Fixed integration tests

* Updated test

* Python: fix(azure-ai): Fix response_format handling for structured outputs (#3114)

* fix(azure-ai): read response_format from chat_options instead of run_options

* refactor: use explicit None checks for response_format

* Fix mypy error

* Mypy fix

* Python: Bump python version to 1.0.0b260107 for a release (#3128)

* Bump python version to 1.0.0b260107 for a release

* Update changelog

* Make A2AAgent public, so that it's concrete implementation methods can be used. (#3119)

* .NET: Map additional props <-> A2A metadata (#3137)

* map additional props from agent run options to a2a request metadata

* small touches

* add unit tests for new extension methods

* Sort using

* add unit test

* add additiona unit tests

* special case json element to avoid unnecessary serialization

* Python: Fix Anthropic streaming response bugs (#3141)

* test commit identity

* fix(anthropic): fix raw_representation and finish_reason in streaming

* lint fix

* Bump AWSSDK.Extensions.Bedrock.MEAI from 4.0.5 to 4.0.5.1 (#2994)

---
updated-dependencies:
- dependency-name: AWSSDK.Extensions.Bedrock.MEAI
  dependency-version: 4.0.5.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* Bump Anthropic from 12.0.0 to 12.0.1 (#2993)

---
updated-dependencies:
- dependency-name: Anthropic
  dependency-version: 12.0.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* .NET: [Breaking] Prevent loss of input messages & streamed updates when resuming streaming (#2748)

* save input messages and stream updates to the continuation token to be able to use them in the last successful stream resumption call.

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/tests/Microsoft.Agents.AI.UnitTests/ChatClient/ChatClientAgent_BackgroundResponsesTests.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* Update dotnet/src/Microsoft.Agents.AI/ChatClient/ChatClientAgentContinuationToken.cs

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

* fix typo

* init continuation token from chat response

* remove unnecessary types for source generation

* remove check for continuation token passed at initial run

* remove check for continuation token pass at initial run

* centralize continuation token parsing

* update xml comments

* use readonly collection instead of enumerable

---------

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

* .NET: fix: Expose WorkflowErrorEvent as ErrorContent (#2762)

* fix: Expose WorkflowErrorEvent as ErrorContent

When hosted using .AsAgent(), Workflows were not exposing inner errors coming as Exceptions (through the WorkflowErrorEvent)

The fix is to convert their message to an ErrorContent on the way out, rather than rely on the default "empty update" to collect the raw event.

* feat: Add a way to show/suppress exception information

* Bump Microsoft.Agents.AI.Workflows from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1 (#2997)

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.Workflows
  dependency-version: 1.0.0-preview.251219.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>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>

* .NET: Add Run overloads to expose ChatClientAgentRunOptions in IntelliSense (#3115)

* Initial plan

* Add ChatClientAgentExtensions for improved discoverability of ChatClientAgentRunOptions

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

* Address code review feedback - use collection expression syntax

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

* Apply suggestion from @westey-m

* Fix issues with Copilot implementation

* Add additional tests for structured output overloads.

---------

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>

* Python: Add tool call/result content types and update connectors and samples (#2971)

* Add new AI content types and image tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Add Python content types for tool calls/results and image generation tool support

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Address review feedback for tool content and samples

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Tighten image generation typing and sample tools list

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Align image generation output typing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Handle MCP naming, image options mapping, and connector tool content

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Allow MCP call in function approval request

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Remove raw image_generation tool remapping

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Restore Anthropic tool_use to function calls unless code execution

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix lint issues for hosted file docstring and MCP parsing

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Import ChatResponse types in Anthropic client

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Fix Anthropics citation type imports and MCP typing for handoff/tools

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* Skip lightning tests without agentlightning and fix function call import

Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>

* fix lint on lab package

* rebuilt anthropic parsing

* redid anthropic parsing

* typo

* updated parsing and added missing docstrings

* fix tests

* mypy fixes

* second mypy fix

* add new class to other samples

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>

* Bump Google.GenAI from 0.6.0 to 0.9.0 (#2995)

---
updated-dependencies:
- dependency-name: Google.GenAI
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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>

* Bump js-yaml from 4.1.0 to 4.1.1 in /python/packages/devui/frontend (#3123)

Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Updated package versions (#3144)

* .NET: Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI (#2996)

* Bump Microsoft.Agents.AI.OpenAI and Microsoft.Extensions.AI.OpenAI

Bumps Microsoft.Agents.AI.OpenAI from 1.0.0-preview.251125.1 to 1.0.0-preview.251219.1
Bumps Microsoft.Extensions.AI.OpenAI from 10.1.0-preview.1.25608.1 to 10.1.1-preview.1.25612.2

---
updated-dependencies:
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Agents.AI.OpenAI
  dependency-version: 1.0.0-preview.251219.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: Microsoft.Extensions.AI.OpenAI
  dependency-version: 10.1.1-preview.1.25612.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed samples

---------

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>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>

* Python: fix(ag-ui): Execute tools with approval_mode, fix shared state, code cleanup  (#3079)

* fix(ag-ui): execute tools after approval in human-in-the-loop flow

* Fix shared state bug

* Bug fix finalized

* Refactoring to clean up code

* Code cleanup

* More fixes

* More code cleanup

* Add version detection in __init__.py to ruff ignore list

* Track agent name with updates for workflow agent (#3146)

* Python: Fix AzureAIClient tool call bug for AG-UI use (#3148)

* Fiz AzureAIClient tool call bug

* Address copilot feedback

* Revert to match main

* revert file to main

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: Kurt <65111699+q33566@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Jose Luis Latorre Millas <joslat@gmail.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Richard Ortega <richardjortega@gmail.com>
Co-authored-by: 刘邦学AI <lbbniu@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Nico Möller <nkm-moeller@mail.de>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
Co-authored-by: Phillip Hoff <phillip.hoff@gmail.com>
Co-authored-by: Ege Ozan Özyedek <36128615+egeozanozyedek@users.noreply.github.com>
Co-authored-by: samueljohnsiby <66901393+samueljohnsiby@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
Co-authored-by: Hao Luo <338265+howlowck@users.noreply.github.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Jacob Viau <javia@microsoft.com>
Co-authored-by: SuperKenVery <39673849+SuperKenVery@users.noreply.github.com>
Co-authored-by: Sunil Dutta <dutta.2003@gmail.com>
Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
Co-authored-by: Syrine Chelly <62653967+SyChell@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
Co-authored-by: takanori-terai <123897708+takanori-terai@users.noreply.github.com>
Co-authored-by: claude89757 <138977524+claude89757@users.noreply.github.com>
Co-authored-by: Gavin Aguiar <80794152+gavin-aguiar@users.noreply.github.com>
Co-authored-by: Sukeesh <vsukeeshbabu@gmail.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <github@vanvalkenburg.eu>
2026-01-09 15:58:49 -08:00
Laveesh RohraandGitHub e3eff65a6b Python: Complete durableagent package (#3058)
* Add worker and clients

* Clean code and refactor common code

* Implement sample

* Add sample

* Update readmes

* Fix tests

* Fix tests

* Update requirements

* Fix typo

* Address comments

* use response.text
2026-01-07 13:53:21 -08:00
+29 a5b36dc379 Python: [Durabletask] Update feature-durabletask-python branch with main (#3068)
* 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

* 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

* Capture file IDs from code interpreter in streaming responses (#2741)

* .NET: [BREAKING] Prevent nulls in AIAgent property (#2719)

* prevent nulls in AIAgent property

* address feedback

* code ql sm04598 (#2723)

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>

* .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>

* 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>

* 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>

* 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>

* 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

* Python: Updated package versions (#2784)

* Updated package versions

* Small fix

* 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>

* .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>

* 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

* Fix WorkflowAgent to include thread convo history. Enable checkpointing. (#2774)

* 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/

* Python: Remove warnings from workflow builder on not using factories (#2808)

* Revert concurrent

* Fix comments

* Python: Filter framework kwargs from MCP tool invocations (#2870)

* Filter framework kwargs from MCP tool invocations

* Fixes

* 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

* 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>

* 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

* 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

* 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

* .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>

* 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>

* 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>

* 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>

* 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>

* Skip failing IT (#2904)

* .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

* .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

* 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

* Updated package versions (#2913)

* .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.

* 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.

* [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

* 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

* Fix Pydantic error when using Literal type for tool params (#2893)

* Updated Ollama package version (#2920)

* Python: Azure AI Agent with Bing Grounding Citations Sample (#2892)

* bing grounding sample with citations

* small fix

* fix

* .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>

* Added additional arguments for Azure AI agent (#2922)

* 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

* Pass kwargs into subworkflows (#2923)

* Python: Move ollama samples to samples getting started dir (#2921)

* Move ollama samples to samples getting started dir

* Address feedback

* 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>

* .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>

* 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

* Python: Workflow add option to visualize internal executors (#2917)

* Workflow add option to visualize internal executors

* Address Copilot comments

* 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

* 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

* .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>

* Python: Add Azure Managed Redis Support with Credential Provider (#2887)

* azure redis support

* small fixes

* azure managed redis sample

* fixes

* 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>

* 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>

* 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>

* Python: Fix WorkflowAgent event handling and kwargs forwarding (#2946)

* Fix kwargs propagation through workflow.as_agent()

* Fix WorkflowAgent to respect AgentExecutor output_response setting

* .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

* 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

* 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

* Updated package versions (#2978)

* Python: Added GitHub MCP sample with PAT (#2967)

* added github mcp sample with PAT

* addressed copilot fixes

* env fix

* 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

* 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

* 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>

* .NET: [Durable Agents] Reliable streaming sample (#2942)

* .NET: [Durable Agents] Reliable streaming sample

* Add automated validation for new sample

* Address Copilot PR feedback

* 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>

* Python: latency improvements (#3014)

* latency improvements

* fixed mypy, added coding standards and instructions

* slight logic improvement

* Python: Updated package versions (#3024)

* Updated package versions

* Updated changelog

* Python: add powerfx safe mode (#3028)

* add powerfx safe mode

* improved docstring and aligned env_file loading

* ensured test uses reset

* .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>

* Remove from feature branch

* Remove ollama changes

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
Co-authored-by: Kurt <65111699+q33566@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <68852919+SergeyMenshykh@users.noreply.github.com>
Co-authored-by: Korolev Dmitry <deagle.gross@gmail.com>
Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Copilot <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>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Jose Luis Latorre Millas <joslat@gmail.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Richard Ortega <richardjortega@gmail.com>
Co-authored-by: 刘邦学AI <lbbniu@gmail.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Nico Möller <nkm-moeller@mail.de>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Giles Odigwe <79032838+giles17@users.noreply.github.com>
Co-authored-by: Phillip Hoff <phillip.hoff@gmail.com>
Co-authored-by: Ege Ozan Özyedek <36128615+egeozanozyedek@users.noreply.github.com>
Co-authored-by: samueljohnsiby <66901393+samueljohnsiby@users.noreply.github.com>
Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
Co-authored-by: Hao Luo <338265+howlowck@users.noreply.github.com>
Co-authored-by: Victor Dibia <chuvidi2003@gmail.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Jacob Viau <javia@microsoft.com>
Co-authored-by: SuperKenVery <39673849+SuperKenVery@users.noreply.github.com>
Co-authored-by: Sunil Dutta <dutta.2003@gmail.com>
Co-authored-by: Sunil Dutta <sunil.dutta@penske.com>
Co-authored-by: budgetboardingai <apurva.sharma31@gmail.com>
Co-authored-by: Syrine Chelly <62653967+SyChell@users.noreply.github.com>
Co-authored-by: SergeyMenshykh <sergemenshikh@gmail.com>
2026-01-02 13:13:37 -08:00
Laveesh RohraandGitHub a02527f00a Python: Add Entity State Providers for DurableTask Package (#2981)
* Add Entity State Providers

* address comments

* Fix tests

* Fix tests

* Revert unrelated changes and remove thread_id

* Revert unrelated files
2025-12-22 12:54:24 -08:00
Laveesh RohraandGitHub 87a38bc7da Python: Rebase durable task feature branch with main (#2806) 2025-12-17 14:02:36 -08:00
Laveesh RohraandGitHub a48a8dd524 Python: Add initial scaffold for durabletask package (#2761)
* Add initial scaffold

* Update design

* Fix mypy and update design

* add additional style considered

* Address comments

* Fix test

* Update readmes
2025-12-17 12:46:08 -08:00
150 changed files with 11528 additions and 4741 deletions
+6 -5
View File
@@ -209,13 +209,14 @@ WARP.md
**/tmpclaude*
# 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
@@ -6,27 +6,16 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for an AI agent, including configuration options for agent hosting behaviour.
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
/// </summary>
/// <param name="Agent">The AI agent.</param>
/// <param name="Options">The options for configuring the AI agent host.
/// </param>
public record AIAgentBinding(AIAgent Agent, AIAgentHostOptions? Options = null)
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
: ExecutorBinding(Throw.IfNull(Agent).GetDescriptiveId(),
(_) => new(new AIAgentHostExecutor(Agent, Options ?? new())),
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
typeof(AIAgentHostExecutor),
Agent)
{
/// <summary>
/// Initializes a new instance of the AIAgentBinding class, associating it with the specified AI agent and
/// optionally enabling event emission.
/// </summary>
/// <param name="agent">The AI agent.</param>
/// <param name="emitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public AIAgentBinding(AIAgent agent, bool emitEvents = false)
: this(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = emitEvents })
{ }
/// <inheritdoc/>
public override bool IsSharedInstance => false;
@@ -1,47 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Configuration options hosting AI Agents as an Executor.
/// </summary>
public sealed class AIAgentHostOptions
{
/// <summary>
/// Gets or sets a value indicating whether agent streaming update events should be emitted during execution.
/// If <see langword="null"/>, the value will be taken from the <see cref="TurnToken"/>
/// </summary>
public bool? EmitAgentUpdateEvents { get; set; }
/// <summary>
/// Gets or sets a value indicating whether aggregated agent response events should be emitted during execution.
/// </summary>
public bool EmitAgentResponseEvents { get; set; }
/// <summary>
/// Gets or sets a value indicating whether <see cref="UserInputRequestContent"/> should be intercepted and sent
/// as a message to the workflow for handling, instead of being raised as a request.
/// </summary>
public bool InterceptUserInputRequests { get; set; }
/// <summary>
/// Gets or sets a value indicating whether <see cref="FunctionCallContent"/> without a corresponding
/// <see cref="FunctionResultContent"/> should be intercepted and sent as a message to the workflow for handling,
/// instead of being raised as a request.
/// </summary>
public bool InterceptUnterminatedFunctionCalls { get; set; }
/// <summary>
/// Gets or sets a value indicating whether other messages from other agents should be assigned to the
/// <see cref="ChatRole.User"/> role during execution.
/// </summary>
public bool ReassignOtherAgentsAsUsers { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether incoming messages are automatically forwarded before new messages generated
/// by the agent during its turn.
/// </summary>
public bool ForwardIncomingMessages { get; set; } = true;
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.AI;
@@ -20,29 +19,6 @@ internal static class AIAgentsAbstractionsExtensions
RawRepresentation = update.RawRepresentation ?? update,
};
public static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName)
=> message.ChatAssistantToUserIfNotFromNamed(agentName, out _, false);
private static ChatMessage ChatAssistantToUserIfNotFromNamed(this ChatMessage message, string agentName, out bool changed, bool inplace = true)
{
changed = false;
if (message.Role == ChatRole.Assistant &&
!StringComparer.Ordinal.Equals(message.AuthorName, agentName) &&
message.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
{
if (!inplace)
{
message = message.Clone();
}
message.Role = ChatRole.User;
changed = true;
}
return message;
}
/// <summary>
/// Iterates through <paramref name="messages"/> looking for <see cref="ChatRole.Assistant"/> messages and swapping
/// any that have a different <see cref="ChatMessage.AuthorName"/> from <paramref name="targetAgentName"/> to
@@ -53,9 +29,11 @@ internal static class AIAgentsAbstractionsExtensions
List<ChatMessage>? roleChanged = null;
foreach (var m in messages)
{
m.ChatAssistantToUserIfNotFromNamed(targetAgentName, out bool changed);
if (changed)
if (m.Role == ChatRole.Assistant &&
m.AuthorName != targetAgentName &&
m.Contents.All(c => c is TextContent or DataContent or UriContent or UsageContent))
{
m.Role = ChatRole.User;
(roleChanged ??= []).Add(m);
}
}
@@ -2,6 +2,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -34,28 +35,38 @@ public static partial class AgentWorkflowBuilder
private static Workflow BuildSequentialCore(string? workflowName, params IEnumerable<AIAgent> agents)
{
Throw.IfNullOrEmpty(agents);
Throw.IfNull(agents);
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
AIAgentHostOptions options = new()
WorkflowBuilder? builder = null;
ExecutorBinding? previous = null;
foreach (var agent in agents)
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
List<ExecutorBinding> agentExecutors = agents.Select(agent => agent.BindAsExecutor(options)).ToList();
if (builder is null)
{
builder = new WorkflowBuilder(agentExecutor);
}
else
{
Debug.Assert(previous is not null);
builder.AddEdge(previous, agentExecutor);
}
ExecutorBinding previous = agentExecutors[0];
WorkflowBuilder builder = new(previous);
foreach (ExecutorBinding next in agentExecutors.Skip(1))
{
builder.AddEdge(previous, next);
previous = next;
previous = agentExecutor;
}
if (previous is null)
{
Throw.ArgumentException(nameof(agents), "At least one agent must be provided to build a sequential workflow.");
}
// Add an ending executor that batches up all messages from the last agent
// so that it's published as a single list result.
Debug.Assert(builder is not null);
OutputMessagesExecutor end = new();
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
if (workflowName is not null)
@@ -114,12 +125,9 @@ public static partial class AgentWorkflowBuilder
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorBinding[] agentExecutors = (from agent in agents
select agent.BindAsExecutor(new AIAgentHostOptions() { ReassignOtherAgentsAsUsers = true })).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new AggregateTurnMessagesExecutor($"Batcher/{agent.Id}")];
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
builder.AddEdge(agentExecutors[i], accumulators[i]);
@@ -2,7 +2,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
@@ -19,12 +18,6 @@ public class ChatProtocolExecutorOptions
/// If set, the executor will accept string messages and convert them to chat messages with this role.
/// </summary>
public ChatRole? StringMessageChatRole { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the executor should automatically send the <see cref="TurnToken"/>
/// after returning from <see cref="ChatProtocolExecutor.TakeTurnAsync(List{ChatMessage}, IWorkflowContext, bool?, CancellationToken)"/>
/// </summary>
public bool AutoSendTurnToken { get; set; } = true;
}
/// <summary>
@@ -33,8 +26,8 @@ public class ChatProtocolExecutorOptions
/// </summary>
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
{
internal static readonly Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatProtocolExecutorOptions _options;
private static readonly Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatRole? _stringMessageChatRole;
private static readonly StatefulExecutorOptions s_baseExecutorOptions = new()
{
@@ -51,28 +44,16 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
: base(id, () => [], s_baseExecutorOptions, declareCrossRunShareable)
{
this._options = options ?? new();
this._stringMessageChatRole = options?.StringMessageChatRole;
}
/// <summary>
/// Gets a value indicating whether string-based messages are supported by this <see cref="ChatProtocolExecutor"/>.
/// </summary>
[MemberNotNullWhen(true, nameof(StringMessageChatRole))]
protected bool SupportsStringMessage => this.StringMessageChatRole.HasValue;
/// <inheritdoc cref="ChatProtocolExecutorOptions.StringMessageChatRole"/>
protected ChatRole? StringMessageChatRole => this._options.StringMessageChatRole;
/// <inheritdoc cref="ChatProtocolExecutorOptions.AutoSendTurnToken"/>
protected bool AutoSendTurnToken => this._options.AutoSendTurnToken;
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this.SupportsStringMessage)
if (this._stringMessageChatRole.HasValue)
{
routeBuilder = routeBuilder.AddHandler<string>(
(message, context) => this.AddMessageAsync(new(this.StringMessageChatRole.Value, message), context));
(message, context) => this.AddMessageAsync(new(this._stringMessageChatRole.Value, message), context));
}
return routeBuilder.AddHandler<ChatMessage>(this.AddMessageAsync)
@@ -136,10 +117,7 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
await this.TakeTurnAsync(maybePendingMessages ?? s_initFunction(), context, token.EmitEvents, cancellationToken)
.ConfigureAwait(false);
if (this.AutoSendTurnToken)
{
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
}
await context.SendMessageAsync(token, cancellationToken: cancellationToken).ConfigureAwait(false);
// Rerun the initialStateFactory to reset the state to empty list. (We could return the empty list directly,
// but this is more consistent if the initial state factory becomes more complex.)
@@ -147,28 +125,6 @@ public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
}
}
/// <summary>
/// Processes the current set of turn messages using the specified asynchronous processing function.
/// </summary>
/// <remarks>If the provided list of chat messages is null, an initial empty list is supplied to the
/// processing function. If the processing function returns null, an empty list is used as the result.</remarks>
/// <param name="processFunc">A delegate that asynchronously processes a list of chat messages within the given workflow context and
/// cancellation token, returning the processed list of chat messages or null.</param>
/// <param name="context">The workflow context in which the messages are processed.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>A ValueTask that represents the asynchronous operation. The result contains the processed list of chat messages,
/// or an empty list if the processing function returns null.</returns>
protected ValueTask ProcessTurnMessagesAsync(Func<List<ChatMessage>, IWorkflowContext, CancellationToken, ValueTask<List<ChatMessage>?>> processFunc, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.InvokeWithStateAsync(InvokeProcessFuncAsync, context, cancellationToken: cancellationToken);
async ValueTask<List<ChatMessage>?> InvokeProcessFuncAsync(List<ChatMessage>? maybePendingMessages, IWorkflowContext context, CancellationToken cancellationToken)
{
return (await processFunc(maybePendingMessages ?? s_initFunction(), context, cancellationToken).ConfigureAwait(false))
?? s_initFunction();
}
}
/// <summary>
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
/// </summary>
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -13,7 +12,7 @@ internal sealed class EdgeMap
{
private readonly Dictionary<EdgeId, EdgeRunner> _edgeRunners = [];
private readonly Dictionary<EdgeId, IStatefulEdgeRunner> _statefulRunners = [];
private readonly ConcurrentDictionary<string, ResponseEdgeRunner> _portEdgeRunners;
private readonly Dictionary<string, ResponseEdgeRunner> _portEdgeRunners;
private readonly ResponseEdgeRunner _inputRunner;
private readonly IStepTracer? _stepTracer;
@@ -52,16 +51,12 @@ internal sealed class EdgeMap
}
}
this._portEdgeRunners = new();
foreach (RequestPort port in workflowPorts)
{
if (!this.TryRegisterPort(runContext, port.Id, port))
{
throw new InvalidOperationException($"Duplicate port ID detected: {port.Id}");
}
}
this._portEdgeRunners = workflowPorts.ToDictionary(
port => port.Id,
port => ResponseEdgeRunner.ForPort(runContext, port)
);
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId, "");
this._inputRunner = new ResponseEdgeRunner(runContext, startExecutorId);
this._stepTracer = stepTracer;
}
@@ -76,9 +71,6 @@ internal sealed class EdgeMap
return edgeRunner.ChaseEdgeAsync(message, this._stepTracer);
}
public bool TryRegisterPort(IRunnerContext runContext, string executorId, RequestPort port)
=> this._portEdgeRunners.TryAdd(port.Id, ResponseEdgeRunner.ForPort(runContext, executorId, port));
public ValueTask<DeliveryMapping?> PrepareDeliveryForInputAsync(MessageEnvelope message)
{
return this._inputRunner.ChaseEdgeAsync(message, this._stepTracer);
@@ -12,6 +12,6 @@ internal interface IRunnerContext : IExternalRequestSink, ISuperStepJoinContext
ValueTask SendMessageAsync(string sourceId, object message, string? targetId = null, CancellationToken cancellationToken = default);
ValueTask<StepContext> AdvanceAsync(CancellationToken cancellationToken = default);
IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null);
IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null);
ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default);
}
@@ -8,19 +8,17 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Execution;
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string executorId, string sinkId)
internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string sinkId)
: EdgeRunner<string>(runContext, sinkId)
{
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, string executorId, RequestPort port)
public static ResponseEdgeRunner ForPort(IRunnerContext runContext, RequestPort port)
{
Throw.IfNull(port);
// The port is an request port, so we can use the port's ID as the sink ID.
return new ResponseEdgeRunner(runContext, executorId, port.Id);
return new ResponseEdgeRunner(runContext, port.Id);
}
public string ExecutorId => executorId;
protected internal override async ValueTask<DeliveryMapping?> ChaseEdgeAsync(MessageEnvelope envelope, IStepTracer? stepTracer)
{
Debug.Assert(envelope.IsExternal, "Input edges should only be chased from external input");
@@ -29,7 +27,7 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
activity?
.SetTag(Tags.EdgeGroupType, nameof(ResponseEdgeRunner))
.SetTag(Tags.MessageSourceId, envelope.SourceId)
.SetTag(Tags.MessageTargetId, $"{this.ExecutorId}[{this.EdgeData}]");
.SetTag(Tags.MessageTargetId, this.EdgeData);
try
{
@@ -50,5 +48,5 @@ internal sealed class ResponseEdgeRunner(IRunnerContext runContext, string execu
}
}
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.ExecutorId, tracer).ConfigureAwait(false);
private async ValueTask<Executor> FindExecutorAsync(IStepTracer? tracer) => await this.RunContext.EnsureExecutorAsync(this.EdgeData, tracer).ConfigureAwait(false);
}
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// A component that processes messages in a <see cref="Workflow"/>.
/// </summary>
[DebuggerDisplay("{GetType().Name}[{Id}]")]
[DebuggerDisplay("{GetType().Name}{Id}")]
public abstract class Executor : IIdentified
{
/// <summary>
@@ -63,24 +63,6 @@ public abstract class Executor : IIdentified
/// </summary>
protected abstract RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder);
internal void Configure(IExternalRequestContext externalRequestContext)
{
// TODO: This is an unfortunate pattern (pending the ability to rework the Configure APIs a bit):
// new()
// >>> will throw InvalidOperationException if Configure() is not invoked when using PortHandlers
// .Configure()
// >>> only usable now
// The fix would be to change the API surface of Executor to have Configure return the contract that the workflow
// will use to invoke the executor (currently the MessageRouter). (Ideally we would rename Executor to Node or similar,
// and the actual Executor class will represent that Contract object)
// Not a terrible issue right now because only InProcessExecution exists right now, and the InProccessRunContext centralizes
// executor instantiation in EnsureExecutorAsync.
this.Router = this.CreateRouter(externalRequestContext);
}
private MessageRouter CreateRouter(IExternalRequestContext? externalRequestContext = null)
=> this.ConfigureRoutes(new RouteBuilder(externalRequestContext)).Build();
/// <summary>
/// Perform any asynchronous initialization required by the executor. This method is called once per executor instance,
/// </summary>
@@ -117,15 +99,12 @@ public abstract class Executor : IIdentified
{
if (field is null)
{
field = this.CreateRouter();
RouteBuilder routeBuilder = this.ConfigureRoutes(new RouteBuilder());
field = routeBuilder.Build();
}
return field;
}
private set
{
field = value;
}
}
/// <summary>
@@ -419,18 +419,9 @@ public static class ExecutorBindingExtensions
/// <param name="agent">The agent instance.</param>
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents)
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
=> new AIAgentBinding(agent, emitEvents);
/// <summary>
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
/// </summary>
/// <param name="agent">The agent instance.</param>
/// <param name="options">Optional configuration options for the AI agent executor. If null, default options are used.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, AIAgentHostOptions? options = null)
=> new AIAgentBinding(agent, options);
/// <summary>
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
/// </summary>
@@ -43,13 +43,4 @@ public record ExternalResponse(RequestPortInfo PortInfo, string RequestId, Porta
/// <param name="targetType">The type to which the data should be cast or converted.</param>
/// <returns>The data cast to the specified type, or null if the data cannot be cast to the specified type.</returns>
public object? DataAs(Type targetType) => this.Data.AsType(targetType);
/// <summary>
/// Attempts to retrieve the underlying data as the specified type.
/// </summary>
/// <param name="targetType">The type to which the data should be cast or converted.</param>
/// <param name="value">When this method returns <see langword="true"/>, contains the value of type
/// <paramref name="targetType"/> if the data is available and compatible.</param>
/// <returns>true if the data is present and can be cast to <paramref name="targetType"/>; otherwise, false.</returns>
public bool DataIs(Type targetType, [NotNullWhen(true)] out object? value) => this.Data.IsType(targetType, out value);
}
@@ -50,14 +50,7 @@ public sealed class GroupChatWorkflowBuilder
public Workflow Build()
{
AIAgent[] agents = this._participants.ToArray();
AIAgentHostOptions options = new()
{
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true
};
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => a.BindAsExecutor(options));
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
(id, runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
@@ -1,10 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
internal interface IExternalRequestContext
{
IExternalRequestSink RegisterPort(RequestPort port);
}
@@ -200,7 +200,7 @@ internal sealed class InProcessRunner : ISuperStepRunner, ICheckpointingHandle
await executor.ExecuteAsync(
envelope.Message,
envelope.MessageType,
this.RunContext.BindWorkflowContext(receiverId, envelope.TraceContext),
this.RunContext.Bind(receiverId, envelope.TraceContext),
cancellationToken
).ConfigureAwait(false);
}
@@ -71,16 +71,6 @@ internal sealed class InProcessRunnerContext : IRunnerContext
this.OutgoingEvents = outgoingEvents;
}
public IExternalRequestSink RegisterPort(string executorId, RequestPort port)
{
if (!this._edgeMap.TryRegisterPort(this, executorId, port))
{
throw new InvalidOperationException($"A port with ID {port.Id} already exists.");
}
return this;
}
public async ValueTask<Executor> EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken = default)
{
this.CheckEnded();
@@ -94,9 +84,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
Executor executor = await registration.CreateInstanceAsync(this._runId).ConfigureAwait(false);
executor.Configure(this.BindExternalRequestContext(executorId));
await executor.InitializeAsync(this.BindWorkflowContext(executorId), cancellationToken: cancellationToken)
await executor.InitializeAsync(this.Bind(executorId), cancellationToken: cancellationToken)
.ConfigureAwait(false);
tracer?.TraceActivated(executorId);
@@ -245,16 +233,10 @@ internal sealed class InProcessRunnerContext : IRunnerContext
}
}
public IExternalRequestContext BindExternalRequestContext(string executorId)
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
{
this.CheckEnded();
return new BoundExternalRequestContext(this, executorId);
}
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
{
this.CheckEnded();
return new BoundWorkflowContext(this, executorId, traceContext);
return new BoundContext(this, executorId, traceContext);
}
public ValueTask PostAsync(ExternalRequest request)
@@ -278,17 +260,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
internal StateManager StateManager { get; } = new();
private sealed class BoundExternalRequestContext(
InProcessRunnerContext RunnerContext,
string ExecutorId) : IExternalRequestContext
{
public IExternalRequestSink RegisterPort(RequestPort port)
{
return RunnerContext.RegisterPort(ExecutorId, port);
}
}
private sealed class BoundWorkflowContext(
private sealed class BoundContext(
InProcessRunnerContext RunnerContext,
string ExecutorId,
Dictionary<string, string>? traceContext) : IWorkflowContext
@@ -340,7 +312,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task InvokeCheckpointingAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointingAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
await executor.OnCheckpointingAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
}
}
@@ -353,7 +325,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task InvokeCheckpointRestoredAsync(Task<Executor> executorTask)
{
Executor executor = await executorTask.ConfigureAwait(false);
await executor.OnCheckpointRestoredAsync(this.BindWorkflowContext(executor.Id), cancellationToken).ConfigureAwait(false);
await executor.OnCheckpointRestoredAsync(this.Bind(executor.Id), cancellationToken).ConfigureAwait(false);
}
}
@@ -2,7 +2,6 @@
<PropertyGroup>
<VersionSuffix>preview</VersionSuffix>
<NoWarn>$(NoWarn);MEAI001</NoWarn>
</PropertyGroup>
<PropertyGroup>
@@ -1,19 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows;
internal class PortBinding(RequestPort port, IExternalRequestSink sink)
{
public RequestPort Port => port;
public IExternalRequestSink Sink => sink;
public ValueTask PostRequestAsync<TRequest>(TRequest request, string? requestId = null, CancellationToken cancellationToken = default)
{
ExternalRequest externalRequest = ExternalRequest.Create(this.Port, request, requestId);
return this.Sink.PostAsync(externalRequest);
}
}
@@ -22,14 +22,6 @@ using MessageHandlerF =
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Execution.CallResult>
>;
using PortHandlerF =
System.Func<
Microsoft.Agents.AI.Workflows.ExternalResponse, // message
Microsoft.Agents.AI.Workflows.IWorkflowContext, // context
System.Threading.CancellationToken, // cancellation
System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.ExternalResponse?>
>;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
@@ -40,17 +32,10 @@ namespace Microsoft.Agents.AI.Workflows;
/// </remarks>
public class RouteBuilder
{
private readonly IExternalRequestContext? _externalRequestContext;
private readonly Dictionary<Type, MessageHandlerF> _typedHandlers = [];
private readonly Dictionary<Type, Type> _outputTypes = [];
private readonly Dictionary<string, PortHandlerF> _portHandlers = [];
private CatchAllF? _catchAll;
internal RouteBuilder(IExternalRequestContext? externalRequestContext)
{
this._externalRequestContext = externalRequestContext;
}
internal RouteBuilder AddHandlerInternal(Type messageType, MessageHandlerF handler, Type? outputType, bool overwrite = false)
{
Throw.IfNull(messageType);
@@ -117,60 +102,6 @@ public class RouteBuilder
}
}
/// <summary>
/// Registers a port and associated handler for external requests originating from the executor. This generates a PortBinding that can be used to
/// submit requests through to the workflow Run call.
/// </summary>
/// <typeparam name="TRequest">The type of request messages that will be sent through this port.</typeparam>
/// <typeparam name="TResponse">The type of response messages that will be sent through this port.</typeparam>
/// <param name="id">A unique identifier for the port.</param>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TResponse"/> within the workflow context. The
/// delegate is invoked for each incoming response to requests through this port.</param>
/// <param name="portBinding">A <see cref="PortBinding"/> representing this port registration providing a means to submit requests.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified response; if a port with this id is not
/// this will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
internal RouteBuilder AddPortHandler<TRequest, TResponse>(string id, Func<TResponse, IWorkflowContext, CancellationToken, ValueTask> handler, out PortBinding portBinding, bool overwrite = false)
{
if (this._externalRequestContext == null)
{
throw new InvalidOperationException("An external request context is required to register port handlers.");
}
RequestPort port = RequestPort.Create<TRequest, TResponse>(id);
IExternalRequestSink sink = this._externalRequestContext!.RegisterPort(port);
portBinding = new(port, sink);
if (this._portHandlers.ContainsKey(id) == overwrite)
{
this._portHandlers[id] = InvokeHandlerAsync;
}
else if (overwrite)
{
throw new InvalidOperationException($"A handler for port id {id} is not registered (overwrite = true).");
}
else
{
throw new InvalidOperationException($"A handler for port id {id} is already registered (overwrite = false).");
}
return this;
async ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
if (!response.DataIs(out TResponse? typedResponse))
{
throw new InvalidOperationException($"Received response data is not of expected type {typeof(TResponse).FullName} for port {port.Id}.");
}
await handler(typedResponse, context, cancellationToken).ConfigureAwait(false);
return response;
}
}
/// <summary>
/// Registers a handler for messages of the specified input type in the workflow route.
/// </summary>
@@ -180,12 +111,10 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -208,12 +137,10 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Action<TInput, IWorkflowContext> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -236,12 +163,10 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -264,12 +189,10 @@ public class RouteBuilder
/// <typeparam name="TInput"></typeparam>
/// <param name="handler">A delegate that processes messages of type <typeparamref name="TInput"/> within the workflow context. The
/// delegate is invoked for each incoming message of the specified type.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the specified input type; otherwise, <see
/// langword="false"/> to preserve the existing handler.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of additional handlers or route
/// options.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput>(Func<TInput, IWorkflowContext, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -293,11 +216,9 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -321,11 +242,9 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -349,11 +268,9 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -362,7 +279,7 @@ public class RouteBuilder
async ValueTask<CallResult> WrappedHandlerAsync(object message, IWorkflowContext context, CancellationToken cancellationToken)
{
TResult result = await handler((TInput)message, context, cancellationToken).ConfigureAwait(false);
TResult result = await handler.Invoke((TInput)message, context, cancellationToken).ConfigureAwait(false);
return CallResult.ReturnResult(result);
}
}
@@ -377,11 +294,9 @@ public class RouteBuilder
/// <typeparam name="TResult">The type of result produced by the handler.</typeparam>
/// <param name="handler">A function that processes messages of type <typeparamref name="TInput"/> within the workflow context and returns
/// a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddHandler<TInput, TResult>(Func<TInput, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -415,11 +330,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -441,11 +354,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Func<PortableValue, IWorkflowContext, ValueTask> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -467,11 +378,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -493,11 +402,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, ValueTask<TResult>> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -519,11 +426,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext, CancellationToken> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -545,11 +450,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context. The delegate is invoked for each incoming message not otherwise handled.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll(Action<PortableValue, IWorkflowContext> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -571,11 +474,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, CancellationToken, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -597,11 +498,9 @@ public class RouteBuilder
/// wrapped as <see cref="PortableValue"/> and workflow context, and returns a result asynchronously.</remarks>
/// <param name="handler">A function that processes messages wrapped as <see cref="PortableValue"/> within the
/// workflow context and returns a <see cref="ValueTask{TResult}"/> representing the asynchronous result.</param>
/// <param name="overwrite">Set <see langword="true"/> to replace an existing handler for the specified input type; if no
/// handler is registered will throw. If set to <see langword="false"/> and a handler is registered, this will throw. </param>
/// <param name="overwrite"><see langword="true"/> to replace any existing handler for the input type; otherwise, <see langword="false"/> to
/// preserve existing handlers.</param>
/// <returns>The current <see cref="RouteBuilder"/> instance, enabling fluent configuration of workflow routes.</returns>
/// <exception cref="InvalidOperationException">If a handler is already registered for the specified type, and overwrite is set
/// to <see langword="false"/>, or if a handler is not already registered, but overwrite is set to <see langword="true"/>.</exception>
public RouteBuilder AddCatchAll<TResult>(Func<PortableValue, IWorkflowContext, TResult> handler, bool overwrite = false)
{
Throw.IfNull(handler);
@@ -615,29 +514,5 @@ public class RouteBuilder
}
}
private void RegisterPortHandlerRouter()
{
Dictionary<string, PortHandlerF> portHandlers = this._portHandlers;
this.AddHandler<ExternalResponse, ExternalResponse?>(InvokeHandlerAsync);
ValueTask<ExternalResponse?> InvokeHandlerAsync(ExternalResponse response, IWorkflowContext context, CancellationToken cancellationToken)
{
if (portHandlers.TryGetValue(response.PortInfo.PortId, out PortHandlerF? portHandler))
{
return portHandler(response, context, cancellationToken);
}
throw new InvalidOperationException($"Unknown port {response.PortInfo}");
}
}
internal MessageRouter Build()
{
if (this._portHandlers.Count > 0)
{
this.RegisterPortHandlerRouter();
}
return new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
}
internal MessageRouter Build() => new(this._typedHandlers, [.. this._outputTypes.Values], this._catchAll);
}
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -10,168 +8,51 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly bool _emitEvents;
private readonly AIAgent _agent;
private readonly AIAgentHostOptions _options;
private AgentThread? _thread;
private bool? _currentTurnEmitEvents;
private AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>? _userInputHandler;
private AIContentExternalHandler<FunctionCallContent, FunctionResultContent>? _functionCallHandler;
private static readonly ChatProtocolExecutorOptions s_defaultChatProtocolOptions = new()
{
AutoSendTurnToken = false,
StringMessageChatRole = ChatRole.User
};
public AIAgentHostExecutor(AIAgent agent, AIAgentHostOptions options) : base(id: agent.GetDescriptiveId(),
s_defaultChatProtocolOptions,
declareCrossRunShareable: false) // Explicitly false, because we maintain turn state on the instance
public AIAgentHostExecutor(AIAgent agent, bool emitEvents = false) : base(id: agent.GetDescriptiveId())
{
this._agent = agent;
this._options = options;
this._emitEvents = emitEvents;
}
private RouteBuilder ConfigureUserInputRoutes(RouteBuilder routeBuilder)
{
this._userInputHandler = new AIContentExternalHandler<UserInputRequestContent, UserInputResponseContent>(
ref routeBuilder,
portId: $"{this.Id}_UserInput",
intercepted: this._options.InterceptUserInputRequests,
handler: this.HandleUserInputResponseAsync);
this._functionCallHandler = new AIContentExternalHandler<FunctionCallContent, FunctionResultContent>(
ref routeBuilder,
portId: $"{this.Id}_FunctionCall",
intercepted: this._options.InterceptUnterminatedFunctionCalls,
handler: this.HandleFunctionResultAsync);
return routeBuilder;
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
routeBuilder = base.ConfigureRoutes(routeBuilder);
return this.ConfigureUserInputRoutes(routeBuilder);
}
private ValueTask HandleUserInputResponseAsync(
UserInputResponseContent response,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._userInputHandler!.MarkRequestAsHandled(response.Id))
{
throw new InvalidOperationException($"No pending UserInputRequest found with id '{response.Id}'.");
}
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])];
// ContinueTurnAsync owns failing to emit a TurnToken if this response does not clear up all remaining outstanding requests.
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
}
private ValueTask HandleFunctionResultAsync(
FunctionResultContent result,
IWorkflowContext context,
CancellationToken cancellationToken)
{
if (!this._functionCallHandler!.MarkRequestAsHandled(result.CallId))
{
throw new InvalidOperationException($"No pending FunctionCall found with id '{result.CallId}'.");
}
List<ChatMessage> implicitTurnMessages = [new ChatMessage(ChatRole.Tool, [result])];
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
}
public bool ShouldEmitStreamingEvents(bool? emitEvents)
=> emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
private async ValueTask<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
private async Task<AgentThread> EnsureThreadAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._thread ??= await this._agent.GetNewThreadAsync(cancellationToken).ConfigureAwait(false);
private const string UserInputRequestStateKey = nameof(_userInputHandler);
private const string FunctionCallRequestStateKey = nameof(_functionCallHandler);
private const string AIAgentHostStateKey = nameof(AIAgentHostState);
private const string ThreadStateKey = nameof(_thread);
protected internal override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
AIAgentHostState state = new(this._thread?.Serialize(), this._currentTurnEmitEvents);
Task coreStateTask = context.QueueStateUpdateAsync(AIAgentHostStateKey, state, cancellationToken: cancellationToken).AsTask();
Task userInputRequestsTask = this._userInputHandler?.OnCheckpointingAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRequestsTask = this._functionCallHandler?.OnCheckpointingAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task threadTask = Task.CompletedTask;
if (this._thread is not null)
{
JsonElement threadValue = this._thread.Serialize();
threadTask = context.QueueStateUpdateAsync(ThreadStateKey, threadValue, cancellationToken: cancellationToken).AsTask();
}
Task baseTask = base.OnCheckpointingAsync(context, cancellationToken).AsTask();
await Task.WhenAll(coreStateTask, userInputRequestsTask, functionCallRequestsTask, baseTask).ConfigureAwait(false);
await Task.WhenAll(threadTask, baseTask).ConfigureAwait(false);
}
protected internal override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
Task userInputRestoreTask = this._userInputHandler?.OnCheckpointRestoredAsync(UserInputRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
Task functionCallRestoreTask = this._functionCallHandler?.OnCheckpointRestoredAsync(FunctionCallRequestStateKey, context, cancellationToken).AsTask() ?? Task.CompletedTask;
AIAgentHostState? state = await context.ReadStateAsync<AIAgentHostState>(AIAgentHostStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (state != null)
JsonElement? threadValue = await context.ReadStateAsync<JsonElement?>(ThreadStateKey, cancellationToken: cancellationToken).ConfigureAwait(false);
if (threadValue.HasValue)
{
this._thread = state.ThreadState.HasValue
? await this._agent.DeserializeThreadAsync(state.ThreadState.Value, cancellationToken: cancellationToken).ConfigureAwait(false)
: null;
this._currentTurnEmitEvents = state.CurrentTurnEmitEvents;
this._thread = await this._agent.DeserializeThreadAsync(threadValue.Value, cancellationToken: cancellationToken).ConfigureAwait(false);
}
await Task.WhenAll(userInputRestoreTask, functionCallRestoreTask).ConfigureAwait(false);
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
}
private bool HasOutstandingRequests => (this._userInputHandler?.HasPendingRequests == true)
|| (this._functionCallHandler?.HasPendingRequests == true);
// While we save this on the instance, we are not cross-run shareable, but as AgentBinding uses the factory pattern this is not an issue
private async ValueTask ContinueTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken)
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
this._currentTurnEmitEvents = emitEvents;
if (this._options.ForwardIncomingMessages)
if (emitEvents ?? this._emitEvents)
{
await context.SendMessageAsync(messages, cancellationToken).ConfigureAwait(false);
}
IEnumerable<ChatMessage> filteredMessages = this._options.ReassignOtherAgentsAsUsers
? messages.Select(m => m.ChatAssistantToUserIfNotFromNamed(this._agent.Name ?? this._agent.Id))
: messages;
AgentResponse response = await this.InvokeAgentAsync(filteredMessages, context, emitEvents, cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages is List<ChatMessage> list ? list : response.Messages.ToList(), cancellationToken)
.ConfigureAwait(false);
// If we have no outstanding requests, we can yield a turn token back to the workflow.
if (!this.HasOutstandingRequests)
{
await context.SendMessageAsync(new TurnToken(this._currentTurnEmitEvents), cancellationToken).ConfigureAwait(false);
this._currentTurnEmitEvents = null; // Possibly not actually necessary, but cleaning this up makes it clearer when debugging
}
}
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
=> this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
private async ValueTask<AgentResponse> InvokeAgentAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
#pragma warning disable MEAI001
Dictionary<string, UserInputRequestContent> userInputRequests = new();
Dictionary<string, FunctionCallContent> functionCalls = new();
AgentResponse response;
if (emitEvents)
{
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
// Run the agent in streaming mode only when agent run update events are to be emitted.
IAsyncEnumerable<AgentResponseUpdate> agentStream = this._agent.RunStreamingAsync(
messages,
@@ -179,70 +60,28 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
cancellationToken: cancellationToken);
List<AgentResponseUpdate> updates = [];
await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
ExtractUnservicedRequests(update.Contents);
// TODO: FunctionCall request handling, and user info request handling.
// In some sense: We should just let it be handled as a ChatMessage, though we should consider
// providing some mechanisms to help the user complete the request, or route it out of the
// workflow.
updates.Add(update);
}
response = updates.ToAgentResponse();
await context.SendMessageAsync(updates.ToAgentResponse().Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
}
else
{
// Otherwise, run the agent in non-streaming mode.
response = await this._agent.RunAsync(messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken)
.ConfigureAwait(false);
ExtractUnservicedRequests(response.Messages.SelectMany(message => message.Contents));
AgentResponse response = await this._agent.RunAsync(
messages,
await this.EnsureThreadAsync(context, cancellationToken).ConfigureAwait(false),
cancellationToken: cancellationToken).ConfigureAwait(false);
await context.SendMessageAsync(response.Messages, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (this._options.EmitAgentResponseEvents == true)
{
await context.AddEventAsync(new AgentResponseEvent(this.Id, response), cancellationToken).ConfigureAwait(false);
}
if (userInputRequests.Count > 0 || functionCalls.Count > 0)
{
Task userInputTask = this._userInputHandler?.ProcessRequestContentsAsync(userInputRequests, context, cancellationToken) ?? Task.CompletedTask;
Task functionCallTask = this._functionCallHandler?.ProcessRequestContentsAsync(functionCalls, context, cancellationToken) ?? Task.CompletedTask;
await Task.WhenAll(userInputTask, functionCallTask)
.ConfigureAwait(false);
}
return response;
void ExtractUnservicedRequests(IEnumerable<AIContent> contents)
{
foreach (AIContent content in contents)
{
if (content is UserInputRequestContent userInputRequest)
{
// It is an error to simultaneously have multiple outstanding user input requests with the same ID.
userInputRequests.Add(userInputRequest.Id, userInputRequest);
}
else if (content is UserInputResponseContent userInputResponse)
{
// If the set of messages somehow already has a corresponding user input response, remove it.
_ = userInputRequests.Remove(userInputResponse.Id);
}
else if (content is FunctionCallContent functionCall)
{
// For function calls, we emit an event to notify the workflow.
//
// possibility 1: this will be handled inline by the agent abstraction
// possibility 2: this will not be handled inline by the agent abstraction
functionCalls.Add(functionCall.CallId, functionCall);
}
else if (content is FunctionResultContent functionResult)
{
_ = functionCalls.Remove(functionResult.CallId);
}
}
}
#pragma warning restore MEAI001
}
}
@@ -1,85 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class AIContentExternalHandler<TRequestContent, TResponseContent>
where TRequestContent : AIContent
where TResponseContent : AIContent
{
private readonly PortBinding? _portBinding;
private ConcurrentDictionary<string, TRequestContent> _pendingRequests = new();
public AIContentExternalHandler(ref RouteBuilder routeBuilder, string portId, bool intercepted, Func<TResponseContent, IWorkflowContext, CancellationToken, ValueTask> handler)
{
if (intercepted)
{
this._portBinding = null;
routeBuilder = routeBuilder.AddHandler(handler);
}
else
{
routeBuilder = routeBuilder.AddPortHandler<TRequestContent, TResponseContent>(portId, handler, out this._portBinding);
}
}
public bool HasPendingRequests => !this._pendingRequests.IsEmpty;
public Task ProcessRequestContentsAsync(Dictionary<string, TRequestContent> requests, IWorkflowContext context, CancellationToken cancellationToken = default)
{
IEnumerable<Task> requestTasks = from string requestId in requests.Keys
select this.ProcessRequestContentAsync(requestId, requests[requestId], context, cancellationToken)
.AsTask();
return Task.WhenAll(requestTasks);
}
public ValueTask ProcessRequestContentAsync(string id, TRequestContent requestContent, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!this._pendingRequests.TryAdd(id, requestContent))
{
throw new InvalidOperationException($"A pending request with ID '{id}' already exists.");
}
return this.IsIntercepted
? context.SendMessageAsync(requestContent, cancellationToken: cancellationToken)
: this._portBinding.PostRequestAsync(requestContent, id, cancellationToken);
}
public bool MarkRequestAsHandled(string id)
{
return this._pendingRequests.TryRemove(id, out _);
}
[MemberNotNullWhen(false, nameof(_portBinding))]
private bool IsIntercepted => this._portBinding == null;
private static string MakeKey(string id) => $"{id}_PendingRequests";
public async ValueTask OnCheckpointingAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Dictionary<string, TRequestContent> pendingRequestsCopy = new(this._pendingRequests);
await context.QueueStateUpdateAsync(MakeKey(id), pendingRequestsCopy, cancellationToken: cancellationToken)
.ConfigureAwait(false);
}
public async ValueTask OnCheckpointRestoredAsync(string id, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Dictionary<string, TRequestContent>? loadedState =
await context.ReadStateAsync<Dictionary<string, TRequestContent>>(MakeKey(id), cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (loadedState != null)
{
this._pendingRequests = new ConcurrentDictionary<string, TRequestContent>(loadedState);
}
}
}
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Executor that runs the agent and forwards all messages, input and output, to the next executor.
/// </summary>
internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInputInOutput)
: ChatProtocolExecutor(agent.GetDescriptiveId(), DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
private static ChatProtocolExecutorOptions DefaultOptions => new()
{
StringMessageChatRole = ChatRole.User
};
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.Name ?? agent.Id);
List<AgentResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
{
updates.Add(update);
if (emitEvents is true)
{
await context.AddEventAsync(new AgentResponseUpdateEvent(this.Id, update), cancellationToken).ConfigureAwait(false);
}
}
roleChanged.ResetUserToAssistantForChangedRoles();
List<ChatMessage> result = includeInputInOutput ? [.. messages] : [];
result.AddRange(updates.ToAgentResponse().Messages);
await context.SendMessageAsync(result, cancellationToken: cancellationToken).ConfigureAwait(false);
}
public new ValueTask ResetAsync() => base.ResetAsync();
}
@@ -8,10 +8,10 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// <summary>
/// Provides an executor that aggregates received chat messages that it then releases when
/// Provides an executor that batches received chat messages that it then releases when
/// receiving a <see cref="TurnToken"/>.
/// </summary>
internal sealed class AggregateTurnMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
internal sealed class CollectChatMessagesExecutor(string id) : ChatProtocolExecutor(id, declareCrossRunShareable: true), IResettableExecutor
{
/// <inheritdoc/>
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
@@ -112,11 +112,18 @@ internal sealed class RequestInfoExecutor : Executor
public async ValueTask<ExternalResponse?> HandleAsync(ExternalResponse message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
if (!this.Port.IsResponsePort(message))
Throw.IfNull(message);
Throw.IfNull(message.Data);
if (message.PortInfo.PortId != this.Port.Id)
{
return null;
}
object data = message.DataAs(this.Port.Response) ??
throw new InvalidOperationException(
$"Message type {message.Data.TypeId} is not assignable to the response type {this.Port.Response.Name} of input port {this.Port.Id}.");
if (this._allowWrapped && this._wrappedRequests.TryGetValue(message.RequestId, out ExternalRequest? originalRequest))
{
await context.SendMessageAsync(originalRequest.RewrapResponse(message), cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -126,11 +133,6 @@ internal sealed class RequestInfoExecutor : Executor
await context.SendMessageAsync(message, cancellationToken: cancellationToken).ConfigureAwait(false);
}
if (!message.Data.IsType(this.Port.Response, out object? data))
{
throw this.Port.CreateExceptionForType(message);
}
await context.SendMessageAsync(data, cancellationToken: cancellationToken).ConfigureAwait(false);
return message;
@@ -1,47 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Specialized;
internal static class RequestPortExtensions
{
/// <summary>
/// Attempts to process the incoming <see cref="ExternalResponse"/> as a response to a request sent
/// through the specified <see cref="RequestPort"/>. If the response is to a different port, returns
/// <see langword="false"/>. If the port matches, but the response data cannot be interpreted as the
/// expected response type, throws an <see cref="InvalidOperationException"/>. Otherwise, returns
/// <see langword="true"/>.
/// </summary>
/// <param name="port">The request port through which the original request was sent.</param>
/// <param name="response">The candidate response to be processed</param>
/// <returns><see langword="true"/> if the response is for the specified port and the data could be
/// interpreted as the expected response type; otherwise, <see langword="false"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if the response is for the specified port,
/// but the data could not be interpreted as the expected response type.</exception>
public static bool ShouldProcessResponse(this RequestPort port, ExternalResponse response)
{
Throw.IfNull(response);
Throw.IfNull(response.Data);
if (!port.IsResponsePort(response))
{
return false;
}
if (!response.Data.IsType(port.Response))
{
throw port.CreateExceptionForType(response);
}
return true;
}
internal static bool IsResponsePort(this RequestPort port, ExternalResponse response)
=> Throw.IfNull(response).PortInfo.PortId == port.Id;
internal static InvalidOperationException CreateExceptionForType(this RequestPort port, ExternalResponse response)
=> new($"Message type {response.Data.TypeId} is not assignable to the response type {port.Response.Name}" +
$" of input port {port.Id}.");
}
@@ -7,7 +7,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
@@ -189,16 +188,6 @@ public class Workflow
await this.TryResetExecutorRegistrationsAsync().ConfigureAwait(false);
}
private sealed class NoOpExternalRequestContext : IExternalRequestContext, IExternalRequestSink
{
public ValueTask PostAsync(ExternalRequest request) => default;
IExternalRequestSink IExternalRequestContext.RegisterPort(RequestPort port)
{
return this;
}
}
/// <summary>
/// Retrieves a <see cref="ProtocolDescriptor"/> defining how to interact with this workflow.
/// </summary>
@@ -210,8 +199,6 @@ public class Workflow
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
startExecutor.Configure(new NoOpExternalRequestContext());
return startExecutor.DescribeProtocol();
}
}
@@ -93,7 +93,7 @@ internal static partial class WorkflowsJsonUtilities
[JsonSerializable(typeof(TurnToken))]
// Built-in Executor State Types
[JsonSerializable(typeof(AIAgentHostState))]
[JsonSerializable(typeof(AIAgentHostExecutor))]
// Event Types
//[JsonSerializable(typeof(WorkflowEvent))]
@@ -1,278 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class AIAgentHostExecutorTests
{
private const string TestAgentId = nameof(TestAgentId);
private const string TestAgentName = nameof(TestAgentName);
private static readonly string[] s_messageStrings = [
"",
"Hello world!",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
];
private static List<ChatMessage> TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
[Theory]
[InlineData(null, null)]
[InlineData(null, true)]
[InlineData(null, false)]
[InlineData(true, null)]
[InlineData(true, true)]
[InlineData(true, false)]
[InlineData(false, null)]
[InlineData(false, true)]
[InlineData(false, false)]
public async Task Test_AgentHostExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
{
// Arrange
TestRunContext testContext = new();
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { EmitAgentUpdateEvents = executorSetting });
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(turnSetting), testContext.BindWorkflowContext(executor.Id));
// Assert
// The rules are: TurnToken overrides Agent, if set. Default to false, if both unset.
bool expectingEvents = turnSetting ?? executorSetting ?? false;
AgentResponseUpdateEvent[] updates = testContext.Events.OfType<AgentResponseUpdateEvent>().ToArray();
if (expectingEvents)
{
// The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
List<AIContent> expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
updates.Should().HaveCount(expectedUpdateContents.Count);
for (int i = 0; i < updates.Length; i++)
{
AgentResponseUpdateEvent updateEvent = updates[i];
AIContent expectedUpdateContent = expectedUpdateContents[i];
updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
AgentResponseUpdate update = updateEvent.Update;
update.AuthorName.Should().Be(TestAgentName);
update.AgentId.Should().Be(TestAgentId);
update.Contents.Should().HaveCount(1);
update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
}
}
else
{
updates.Should().BeEmpty();
}
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
{
// Arrange
TestRunContext testContext = new();
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { EmitAgentResponseEvents = executorSetting });
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
AgentResponseEvent[] updates = testContext.Events.OfType<AgentResponseEvent>().ToArray();
if (executorSetting)
{
updates.Should().HaveCount(1);
AgentResponseEvent responseEvent = updates[0];
responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
AgentResponse response = responseEvent.Response;
response.AgentId.Should().Be(TestAgentId);
response.Messages.Should().HaveCount(TestMessages.Count - 1);
for (int i = 0; i < response.Messages.Count; i++)
{
ChatMessage responseMessage = response.Messages[i];
ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
responseMessage.AuthorName.Should().Be(TestAgentName);
responseMessage.Text.Should().Be(expectedMessage.Text);
}
}
else
{
updates.Should().BeEmpty();
}
}
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
private static ChatMessage AssistantMessage => new(ChatRole.Assistant, "Hello from Assistant!") { AuthorName = "User" };
private static ChatMessage TestAgentMessage => new(ChatRole.Assistant, $"Hello from {TestAgentName}!") { AuthorName = TestAgentName };
[Theory]
[InlineData(true, true, false, false)]
[InlineData(true, true, false, true)]
[InlineData(true, true, true, false)]
[InlineData(true, true, true, true)]
[InlineData(true, false, false, false)]
[InlineData(true, false, false, true)]
[InlineData(true, false, true, false)]
[InlineData(true, false, true, true)]
[InlineData(false, true, false, false)]
[InlineData(false, true, false, true)]
[InlineData(false, true, true, false)]
[InlineData(false, true, true, true)]
[InlineData(false, false, false, false)]
[InlineData(false, false, false, true)]
[InlineData(false, false, true, false)]
[InlineData(false, false, true, true)]
public async Task Test_AgentHostExecutor_ReassignsRolesIFFConfiguredAsync(bool executorSetting, bool includeUser, bool includeSelfMessages, bool includeOtherMessages)
{
// Arrange
TestRunContext testContext = new();
RoleCheckAgent agent = new(false, TestAgentId, TestAgentName);
AIAgentHostExecutor executor = new(agent, new() { ReassignOtherAgentsAsUsers = executorSetting });
testContext.ConfigureExecutor(executor);
List<ChatMessage> messages = [];
if (includeUser)
{
messages.Add(UserMessage);
}
if (includeSelfMessages)
{
messages.Add(TestAgentMessage);
}
if (includeOtherMessages)
{
messages.Add(AssistantMessage);
}
// Act
await executor.Router.RouteMessageAsync(messages, testContext.BindWorkflowContext(executor.Id));
Func<Task> act = async () => await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
bool shouldThrow = includeOtherMessages && !executorSetting;
if (shouldThrow)
{
await act.Should().ThrowAsync<InvalidOperationException>();
}
else
{
await act.Should().NotThrowAsync();
}
}
[Theory]
[InlineData(true, TestAgentRequestType.FunctionCall)]
[InlineData(false, TestAgentRequestType.FunctionCall)]
//[InlineData(true, TestAgentRequestType.UserInputRequest)] TODO: Enable when we support polymorphic routing
[InlineData(false, TestAgentRequestType.UserInputRequest)]
public async Task Test_AgentHostExecutor_InterceptsRequestsIFFConfiguredAsync(bool intercept, TestAgentRequestType requestType)
{
const int UnpairedRequestCount = 2;
const int PairedRequestCount = 3;
// Arrange
TestRunContext testContext = new();
TestRequestAgent agent = new(requestType, UnpairedRequestCount, PairedRequestCount, TestAgentId, TestAgentName);
AIAgentHostOptions agentHostOptions = requestType switch
{
TestAgentRequestType.FunctionCall =>
new()
{
EmitAgentResponseEvents = true,
InterceptUnterminatedFunctionCalls = intercept
},
TestAgentRequestType.UserInputRequest =>
new()
{
EmitAgentResponseEvents = true,
InterceptUserInputRequests = intercept
},
_ => throw new NotSupportedException()
};
AIAgentHostExecutor executor = new(agent, agentHostOptions);
testContext.ConfigureExecutor(executor);
// Act
await executor.TakeTurnAsync(new(), testContext.BindWorkflowContext(executor.Id));
// Assert
List<object> responses;
if (intercept)
{
// We expect to have a sent message containing the requests as an ExternalRequest
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
responses = ExtractAndValidateRequestContents<FunctionCallContent>();
break;
case TestAgentRequestType.UserInputRequest:
responses = ExtractAndValidateRequestContents<UserInputRequestContent>();
break;
default:
throw new NotSupportedException();
}
List<object> ExtractAndValidateRequestContents<TRequest>() where TRequest : AIContent
{
IEnumerable<TRequest> requests = testContext.QueuedMessages.Should().ContainKey(executor.Id)
.WhoseValue
.Select(envelope => envelope.Message as TRequest)
.Where(item => item is not null)
.Select(item => item!);
return agent.ValidateUnpairedRequests(requests).ToList();
}
}
else
{
responses = agent.ValidateUnpairedRequests([.. testContext.ExternalRequests]).ToList<object>();
}
// Act 2
foreach (object response in responses.Take(UnpairedRequestCount - 1))
{
await executor.Router.RouteMessageAsync(response, testContext.BindWorkflowContext(executor.Id));
}
// Assert 2
// Since we are not finished, we expect the agent to not have produced a final response (="Remaining: 1")
AgentResponseEvent lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
.And.Subject.Last();
lastResponseEvent.Response.Text.Should().Be("Remaining: 1");
// Act 3
object finalResponse = responses.Last();
await executor.Router.RouteMessageAsync(finalResponse, testContext.BindWorkflowContext(executor.Id));
// Assert 3
// Now that we are finished, we expect the agent to have produced a final response
lastResponseEvent = testContext.Events.OfType<AgentResponseEvent>().Should().NotBeEmpty()
.And.Subject.Last();
lastResponseEvent.Response.Text.Should().Be("Done");
}
}
@@ -1,38 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class DynamicPortsExecutor<TRequest, TResponse>(string id, params IEnumerable<string> ports) : Executor(id)
{
public Dictionary<string, PortBinding> PortBindings { get; } = new();
public ConcurrentDictionary<string, ConcurrentQueue<TResponse>> ReceivedResponses { get; } = new();
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
foreach (string portId in ports)
{
routeBuilder = routeBuilder
.AddPortHandler<TRequest, TResponse>(portId,
(response, context, cancellationToken) =>
{
this.ReceivedResponses.GetOrAdd(portId, _ => new()).Enqueue(response);
return default;
}, out PortBinding? binding);
this.PortBindings[portId] = binding;
}
return routeBuilder;
}
public ValueTask PostRequestAsync(string portId, TRequest request, TestRunContext testContext, string? requestId = null)
{
PortBinding binding = this.PortBindings[portId];
return binding.Sink.PostAsync(ExternalRequest.Create(binding.Port, request, requestId));
}
}
@@ -1,99 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class DynamicRequestPortTests
{
private sealed class RequestPortTestContext
{
private const string PortId = "Port1";
private const string ExecutorId = "Executor1";
public RequestPortTestContext()
{
this.Executor = new(ExecutorId, PortId);
this.Executor.Configure(this.ExternalRequestContext);
}
public TestRunContext RunContext { get; } = new();
public ExternalRequestContext ExternalRequestContext { get; } = new();
public DynamicPortsExecutor<string, int> Executor { get; }
public PortBinding PortBinding => this.Executor.PortBindings[PortId];
public ExternalRequest Request => this.ExternalRequestContext.ExternalRequests[0];
public static async ValueTask<RequestPortTestContext> CreateAsync(string requestData = "Request", bool validate = true)
{
RequestPortTestContext result = new();
await result.Executor.PostRequestAsync(PortId, requestData, result.RunContext);
if (validate)
{
result.ExternalRequestContext
.ExternalRequests.Should().HaveCount(1)
.And.AllSatisfy(request => request.PortInfo.Should().Be(result.PortBinding.Port.ToPortInfo()));
}
return result;
}
public ValueTask<object?> InvokeExecutorWithResponseAsync(ExternalResponse response)
=> this.Executor.ExecuteAsync(response, new(typeof(ExternalResponse)), this.RunContext.BindWorkflowContext(this.Executor.Id));
}
private sealed class ExternalRequestContext : IExternalRequestContext, IExternalRequestSink
{
public List<ExternalRequest> ExternalRequests { get; } = new();
public ValueTask PostAsync(ExternalRequest request)
{
this.ExternalRequests.Add(request);
return default;
}
public IExternalRequestSink RegisterPort(RequestPort port)
{
return this;
}
}
[Fact]
public async Task Test_DynamicRequestPort_DeliversExpectedResponseAsync()
{
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
ExternalRequest request = context.Request;
await context.InvokeExecutorWithResponseAsync(request.CreateResponse(13));
string portId = request.PortInfo.PortId;
context.Executor.ReceivedResponses.Should().HaveCount(1)
.And.ContainKey(portId);
context.Executor.ReceivedResponses[portId].Should().HaveCount(1);
context.Executor.ReceivedResponses[portId].First().Should().Be(13);
}
[Fact]
public async Task Test_DynamicRequestPort_ThrowsOnWrongPortAsync()
{
RequestPortTestContext context = await RequestPortTestContext.CreateAsync();
ExternalRequest request = context.Request;
ExternalRequest fakeRequest = new(RequestPort.Create<string, int>("port2").ToPortInfo(), request.RequestId, request.Data);
Func<Task> act = async () => await context.InvokeExecutorWithResponseAsync(fakeRequest.CreateResponse(13));
(await act.Should().ThrowAsync<TargetInvocationException>())
.WithInnerException<InvalidOperationException>();
}
}
@@ -1,95 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Execution;
using Microsoft.Agents.AI.Workflows.Specialized;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class EdgeMapSmokeTests
{
[Fact]
public async Task Test_EdgeMap_RoutesStaticPortAsync()
{
TestRunContext runContext = new();
RequestPort staticPort = RequestPort.Create<string, int>("port1");
RequestInfoExecutor executor = new(staticPort);
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
runContext.ConfigureExecutor(executor, edgeMap);
ExternalResponse responseMessage = new(staticPort.ToPortInfo(), "Request1", new(12));
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
deliveries[0].Envelope.Message.Should().Be(responseMessage);
}
[Fact]
public async Task Test_EdgeMap_RoutesDynamicPortAsync()
{
TestRunContext runContext = new();
DynamicPortsExecutor<string, int> executor = new("executor1", "port1", "port2");
EdgeMap edgeMap = new(runContext, [], [], executor.Id, null);
runContext.ConfigureExecutor(executor, edgeMap);
await RunPortTestAsync("port1");
await RunPortTestAsync("port2");
async ValueTask RunPortTestAsync(string portId)
{
PortBinding binding = executor.PortBindings[portId];
ExternalResponse responseMessage = new(binding.Port.ToPortInfo(), $"RequestFor[{portId}]", new(10));
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
mapping.Should().NotBeNull();
List<MessageDelivery> deliveries = mapping.Deliveries.ToList();
deliveries.Should().HaveCount(1).And.AllSatisfy(delivery => delivery.TargetId.Should().Be(executor.Id));
deliveries[0].Envelope.Message.Should().Be(responseMessage);
}
}
[Fact]
public async Task Test_EdgeMap_DoesNotRouteUnregisteredPortAsync()
{
TestRunContext runContext = new();
RequestPort staticPort = RequestPort.Create<string, int>("port1");
RequestInfoExecutor staticExecutor = new(staticPort);
DynamicPortsExecutor<string, int> executor = new("executor1", "port2", "port3");
EdgeMap edgeMap = new(runContext, [], [staticPort], executor.Id, null);
runContext.ConfigureExecutors([staticExecutor, executor], edgeMap);
await RunPortTestAsync("port4");
async ValueTask RunPortTestAsync(string portId)
{
RequestPort fakePort = RequestPort.Create<string, int>(portId);
ExternalResponse responseMessage = new(fakePort.ToPortInfo(), $"RequestFor[{portId}]", new(10));
Func<Task<DeliveryMapping?>> mappingTask = async () => await edgeMap.PrepareDeliveryForResponseAsync(responseMessage);
await mappingTask.Should().ThrowAsync<InvalidOperationException>();
}
}
[Fact]
public async Task Test_EdgeMap_MaintainsFanInEdgeStateAsync()
{
TestRunContext runContext = new();
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
Dictionary<string, HashSet<Edge>> workflowEdges = [];
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
@@ -97,14 +26,8 @@ public class EdgeMapSmokeTests
workflowEdges["executor1"] = [fanInEdge];
workflowEdges["executor2"] = [fanInEdge];
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
runContext.ConfigureExecutors(
[
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
], edgeMap);
EdgeMap edgeMap = new(runContext, workflowEdges, [], "executor1", null);
DeliveryMapping? mapping = await edgeMap.PrepareDeliveryForEdgeAsync(fanInEdge, new("part1", "executor1"));
mapping.Should().BeNull();
@@ -28,11 +28,9 @@ public class EdgeRunnerTests
: null;
TestRunContext runContext = new();
runContext.ConfigureExecutors(
[
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2")
]);
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
DirectEdgeData edgeData = new("executor1", "executor2", new EdgeId(0), condition);
DirectEdgeRunner runner = new(runContext, edgeData);
@@ -80,11 +78,9 @@ public class EdgeRunnerTests
{
TestRunContext runContext = new();
runContext.ConfigureExecutors([
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
]);
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
Func<object?, int, IEnumerable<int>>? assigner
= assignerSelectsEmpty.HasValue
@@ -154,11 +150,10 @@ public class EdgeRunnerTests
public async Task Test_FanInEdgeRunnerAsync()
{
TestRunContext runContext = new();
runContext.ConfigureExecutors([
new ForwardMessageExecutor<string>("executor1"),
new ForwardMessageExecutor<string>("executor2"),
new ForwardMessageExecutor<string>("executor3")
]);
runContext.Executors["executor1"] = new ForwardMessageExecutor<string>("executor1");
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
@@ -85,7 +85,7 @@ public class RepresentationTests
[Fact]
public async Task Test_SpecializedExecutor_InfosAsync()
{
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent(), new()));
await RunExecutorBindingInfoMatchTestAsync(new AIAgentHostExecutor(new TestAgent()));
await RunExecutorBindingInfoMatchTestAsync(new RequestInfoExecutor(TestRequestPort));
}
@@ -1,47 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
internal sealed class RoleCheckAgent(bool allowOtherAssistantRoles, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new RoleCheckAgentThread());
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default) => new(new RoleCheckAgentThread());
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (ChatMessage message in messages)
{
if (!allowOtherAssistantRoles && message.Role == ChatRole.Assistant && !(message.AuthorName == null || message.AuthorName == this.Name))
{
throw new InvalidOperationException($"Message from other assistant role detected: AuthorName={message.AuthorName}");
}
}
yield return new AgentResponseUpdate(ChatRole.Assistant, "Ok")
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = Guid.NewGuid().ToString("N"),
ResponseId = Guid.NewGuid().ToString("N")
};
}
private sealed class RoleCheckAgentThread : InMemoryAgentThread;
}
@@ -2,6 +2,9 @@
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;
@@ -14,6 +17,102 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class SpecializedExecutorSmokeTests
{
public class TestAIAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
static ChatMessage ToMessage(string text)
{
if (string.IsNullOrEmpty(text))
{
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
}
string[] splits = text.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += ' ';
}
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
return new(ChatRole.Assistant, contents)
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.UtcNow,
};
}
return result;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new TestAgentThread());
public static TestAIAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new AgentResponse(this.Messages)
{
AgentId = this.Id,
ResponseId = Guid.NewGuid().ToString("N")
});
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
{
foreach (AIContent content in message.Contents)
{
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
{
string? currentMessageId = null;
if (candidateMessages is not null)
{
foreach (ChatMessage message in candidateMessages)
{
if (currentMessageId is null)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
return candidateMessages;
}
}
public sealed class TestAgentThread() : InMemoryAgentThread();
internal sealed class TestWorkflowContext(string executorId, bool concurrentRunsEnabled = false) : IWorkflowContext
{
private readonly StateManager _stateManager = new();
@@ -78,10 +177,10 @@ public class SpecializedExecutorSmokeTests
"Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
];
List<ChatMessage> expected = TestReplayAgent.ToChatMessages(MessageStrings);
List<ChatMessage> expected = TestAIAgent.ToChatMessages(MessageStrings);
TestReplayAgent agent = new(expected);
AIAgentHostExecutor host = new(agent, new());
TestAIAgent agent = new(expected);
AIAgentHostExecutor host = new(agent);
TestWorkflowContext collectingContext = new(host.Id);
@@ -104,8 +203,8 @@ public class SpecializedExecutorSmokeTests
{
const string AgentAName = "TestAgentAName";
const string AgentBName = "TestAgentBName";
TestReplayAgent agentA = new(name: AgentAName);
TestReplayAgent agentB = new(name: AgentBName);
TestAIAgent agentA = new(name: AgentAName);
TestAIAgent agentB = new(name: AgentBName);
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
@@ -126,8 +225,8 @@ public class SpecializedExecutorSmokeTests
[Fact]
public async Task Test_AIAgent_ExecutorId_Use_Agent_ID_When_Name_Not_ProvidedAsync()
{
TestReplayAgent agentA = new();
TestReplayAgent agentB = new();
TestAIAgent agentA = new();
TestAIAgent agentB = new();
var workflow = new WorkflowBuilder(agentA).AddEdge(agentA, agentB).Build();
var definition = workflow.ToWorkflowInfo();
@@ -1,105 +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 Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestReplayAgent(List<ChatMessage>? messages = null, string? id = null, string? name = null) : AIAgent
{
protected override string? IdCore => id;
public override string? Name => name;
public static List<ChatMessage> ToChatMessages(params string[] messages)
{
List<ChatMessage> result = messages.Select(ToMessage).ToList();
static ChatMessage ToMessage(string text)
{
if (string.IsNullOrEmpty(text))
{
return new ChatMessage(ChatRole.Assistant, "") { MessageId = "" };
}
string[] splits = text.Split(' ');
for (int i = 0; i < splits.Length - 1; i++)
{
splits[i] += ' ';
}
List<AIContent> contents = splits.Select<string, AIContent>(text => new TextContent(text) { RawRepresentation = text }).ToList();
return new(ChatRole.Assistant, contents)
{
MessageId = Guid.NewGuid().ToString("N"),
RawRepresentation = text,
CreatedAt = DateTime.UtcNow,
};
}
return result;
}
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken = default)
=> new(new ReplayAgentThread());
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(new ReplayAgentThread());
public static TestReplayAgent FromStrings(params string[] messages) =>
new(ToChatMessages(messages));
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");
foreach (ChatMessage message in this.Messages)
{
foreach (AIContent content in message.Contents)
{
yield return new AgentResponseUpdate()
{
AgentId = this.Id,
AuthorName = this.Name,
MessageId = message.MessageId,
ResponseId = responseId,
Contents = [content],
Role = message.Role,
};
}
}
}
private static List<ChatMessage>? Validate(List<ChatMessage>? candidateMessages)
{
string? currentMessageId = null;
if (candidateMessages is not null)
{
foreach (ChatMessage message in candidateMessages)
{
if (currentMessageId is null)
{
currentMessageId = message.MessageId;
}
else if (currentMessageId == message.MessageId)
{
throw new ArgumentException("Duplicate consecutive message ids");
}
}
}
return candidateMessages;
}
private sealed class ReplayAgentThread() : InMemoryAgentThread();
}
@@ -1,378 +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;
internal sealed record TestRequestAgentThreadState(JsonElement ThreadState, Dictionary<string, PortableValue> UnservicedRequests, HashSet<string> ServicedRequests, HashSet<string> PairedRequests);
public enum TestAgentRequestType
{
FunctionCall,
UserInputRequest
}
internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unpairedRequestCount, int pairedRequestCount, string? id, string? name) : AIAgent
{
public Random RNG { get; set; } = new Random(HashCode.Combine(requestType, nameof(TestRequestAgent)));
public AgentThread? LastThread { get; set; }
protected override string? IdCore => id;
public override string? Name => name;
public override ValueTask<AgentThread> GetNewThreadAsync(CancellationToken cancellationToken)
=> new(requestType switch
{
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
_ => throw new NotSupportedException(),
});
public override ValueTask<AgentThread> DeserializeThreadAsync(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> new(requestType switch
{
TestAgentRequestType.FunctionCall => new TestRequestAgentThread<FunctionCallContent, FunctionResultContent>(),
TestAgentRequestType.UserInputRequest => new TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent>(),
_ => throw new NotSupportedException(),
});
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
private static int[] SampleIndicies(Random rng, int n, int c)
{
int[] result = Enumerable.Range(0, c).ToArray();
for (int i = c; i < n; i++)
{
int radix = rng.Next(i);
if (radix < c)
{
result[radix] = i;
}
}
return result;
}
private async IAsyncEnumerable<AgentResponseUpdate> RunStreamingAsync<TRequest, TResponse>(
IRequestResponseStrategy<TRequest, TResponse> strategy,
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
where TRequest : AIContent
where TResponse : AIContent
{
this.LastThread = thread ??= await this.GetNewThreadAsync(cancellationToken);
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(thread);
if (traThread.HasSentRequests)
{
foreach (TResponse response in messages.SelectMany(message => message.Contents).OfType<TResponse>())
{
strategy.ProcessResponse(response, traThread);
}
if (traThread.UnservicedRequests.Count == 0)
{
yield return new(ChatRole.Assistant, "Done");
}
else
{
yield return new(ChatRole.Assistant, $"Remaining: {traThread.UnservicedRequests.Count}");
}
}
else
{
int totalRequestCount = unpairedRequestCount + pairedRequestCount;
yield return new(ChatRole.Assistant, $"Creating {totalRequestCount} requests, {pairedRequestCount} paired.");
HashSet<int> servicedIndicies = [.. SampleIndicies(this.RNG, totalRequestCount, pairedRequestCount)];
(string, TRequest)[] requests = strategy.CreateRequests(unpairedRequestCount + pairedRequestCount).ToArray();
List<AIContent> pairedResponses = new(capacity: pairedRequestCount);
for (int i = 0; i < requests.Length; i++)
{
(string id, TRequest request) = requests[i];
if (servicedIndicies.Contains(i))
{
traThread.PairedRequests.Add(id);
pairedResponses.Add(strategy.CreatePairedResponse(request));
}
else
{
traThread.UnservicedRequests.Add(id, request);
}
yield return new(ChatRole.Assistant, [request]);
}
yield return new(ChatRole.Assistant, pairedResponses);
traThread.HasSentRequests = true;
}
}
private static TestRequestAgentThread<TRequest, TResponse> ConvertThread<TRequest, TResponse>(AgentThread thread)
where TRequest : AIContent
where TResponse : AIContent
{
if (thread is not TestRequestAgentThread<TRequest, TResponse> traThread)
{
throw new ArgumentException($"Bad AgentThread type: Expected {typeof(TestRequestAgentThread<TRequest, TResponse>)}, got {thread.GetType()}.", nameof(thread));
}
return traThread;
}
private sealed class FunctionCallStrategy : IRequestResponseStrategy<FunctionCallContent, FunctionResultContent>
{
public FunctionResultContent CreatePairedResponse(FunctionCallContent request)
{
return new FunctionResultContent(request.CallId, request);
}
public IEnumerable<(string, FunctionCallContent)> CreateRequests(int count)
{
for (int i = 0; i < count; i++)
{
string callId = Guid.NewGuid().ToString("N");
FunctionCallContent request = new(callId, "TestFunction");
yield return (callId, request);
}
}
public void ProcessResponse(FunctionResultContent response, TestRequestAgentThread<FunctionCallContent, FunctionResultContent> thread)
{
if (thread.UnservicedRequests.TryGetValue(response.CallId, out FunctionCallContent? request))
{
response.Result.As<FunctionCallContent>().Should().Be(request);
thread.ServicedRequests.Add(response.CallId);
thread.UnservicedRequests.Remove(response.CallId);
}
else if (thread.ServicedRequests.Contains(response.CallId))
{
throw new InvalidOperationException($"Seeing duplicate response with id {response.CallId}");
}
else if (thread.PairedRequests.Contains(response.CallId))
{
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.CallId}");
}
else
{
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.CallId}");
}
}
}
private sealed class FunctionApprovalStrategy : IRequestResponseStrategy<UserInputRequestContent, UserInputResponseContent>
{
public UserInputResponseContent CreatePairedResponse(UserInputRequestContent request)
{
if (request is not FunctionApprovalRequestContent approvalRequest)
{
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
}
return new FunctionApprovalResponseContent(approvalRequest.Id, true, approvalRequest.FunctionCall);
}
public IEnumerable<(string, UserInputRequestContent)> CreateRequests(int count)
{
for (int i = 0; i < count; i++)
{
string id = Guid.NewGuid().ToString("N");
UserInputRequestContent request = new FunctionApprovalRequestContent(id, new(id, "TestFunction"));
yield return (id, request);
}
}
public void ProcessResponse(UserInputResponseContent response, TestRequestAgentThread<UserInputRequestContent, UserInputResponseContent> thread)
{
if (thread.UnservicedRequests.TryGetValue(response.Id, out UserInputRequestContent? request))
{
if (request is not FunctionApprovalRequestContent approvalRequest)
{
throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}");
}
if (response is not FunctionApprovalResponseContent approvalResponse)
{
throw new InvalidOperationException($"Invalid response: Expecting {typeof(FunctionApprovalResponseContent)}, got {response.GetType()}");
}
approvalResponse.Approved.Should().BeTrue();
approvalResponse.FunctionCall.As<FunctionCallContent>().Should().Be(approvalRequest.FunctionCall);
thread.ServicedRequests.Add(response.Id);
thread.UnservicedRequests.Remove(response.Id);
}
else if (thread.ServicedRequests.Contains(response.Id))
{
throw new InvalidOperationException($"Seeing duplicate response with id {response.Id}");
}
else if (thread.PairedRequests.Contains(response.Id))
{
throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.Id}");
}
else
{
throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.Id}");
}
}
}
private interface IRequestResponseStrategy<TRequest, TResponse>
where TRequest : AIContent
where TResponse : AIContent
{
IEnumerable<(string, TRequest)> CreateRequests(int count);
TResponse CreatePairedResponse(TRequest request);
void ProcessResponse(TResponse response, TestRequestAgentThread<TRequest, TResponse> thread);
}
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return requestType switch
{
TestAgentRequestType.FunctionCall => this.RunStreamingAsync(new FunctionCallStrategy(), messages, thread, options, cancellationToken),
TestAgentRequestType.UserInputRequest => this.RunStreamingAsync(new FunctionApprovalStrategy(), messages, thread, options, cancellationToken),
_ => throw new NotSupportedException($"Unknown AgentRequestType {requestType}"),
};
}
private static string RetrieveId<TRequest>(TRequest request)
where TRequest : AIContent
{
return request switch
{
FunctionCallContent functionCall => functionCall.CallId,
UserInputRequestContent userInputRequest => userInputRequest.Id,
_ => throw new NotSupportedException($"Unknown request type {typeof(TRequest)}"),
};
}
private IEnumerable<TResponse> ValidateUnpairedRequests<TRequest, TResponse>(IEnumerable<TRequest> requests, IRequestResponseStrategy<TRequest, TResponse> strategy)
where TRequest : AIContent
where TResponse : AIContent
{
this.LastThread.Should().NotBeNull();
TestRequestAgentThread<TRequest, TResponse> traThread = ConvertThread<TRequest, TResponse>(this.LastThread);
requests.Should().HaveCount(traThread.UnservicedRequests.Count);
foreach (TRequest request in requests)
{
string requestId = RetrieveId(request);
traThread.UnservicedRequests.Should().ContainKey(requestId);
yield return strategy.CreatePairedResponse(request);
}
}
internal IEnumerable<object> ValidateUnpairedRequests<TRequest>(IEnumerable<TRequest> requests)
where TRequest : AIContent
{
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
if (typeof(TRequest) != typeof(FunctionCallContent))
{
throw new ArgumentException($"Invalid request type: Expected {typeof(FunctionCallContent)}, got {typeof(TRequest)}", nameof(requests));
}
return this.ValidateUnpairedRequests((IEnumerable<FunctionCallContent>)requests, new FunctionCallStrategy());
case TestAgentRequestType.UserInputRequest:
if (!typeof(UserInputRequestContent).IsAssignableFrom(typeof(TRequest)))
{
throw new ArgumentException($"Invalid request type: Expected {typeof(UserInputRequestContent)}, got {typeof(TRequest)}", nameof(requests));
}
return this.ValidateUnpairedRequests((IEnumerable<UserInputRequestContent>)requests, new FunctionApprovalStrategy());
default:
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
}
}
internal IEnumerable<ExternalResponse> ValidateUnpairedRequests(List<ExternalRequest> requests)
{
List<object> responses;
switch (requestType)
{
case TestAgentRequestType.FunctionCall:
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<FunctionCallContent>)).ToList();
break;
case TestAgentRequestType.UserInputRequest:
responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent<UserInputRequestContent>)).ToList();
break;
default:
throw new NotSupportedException($"Unknown AgentRequestType {requestType}");
}
return Enumerable.Zip(requests, responses, (ExternalRequest request, object response) => request.CreateResponse(response));
static TRequest AssertAndExtractRequestContent<TRequest>(ExternalRequest request)
{
request.DataIs(out TRequest? content).Should().BeTrue();
return content!;
}
}
private sealed class TestRequestAgentThread<TRequest, TResponse> : InMemoryAgentThread
where TRequest : AIContent
where TResponse : AIContent
{
public TestRequestAgentThread()
{
}
public bool HasSentRequests { get; set; }
public Dictionary<string, TRequest> UnservicedRequests { get; } = new();
public HashSet<string> ServicedRequests { get; } = new();
public HashSet<string> PairedRequests { get; } = new();
private static JsonElement DeserializeAndExtractState(JsonElement serializedState,
out TestRequestAgentThreadState state,
JsonSerializerOptions? jsonSerializerOptions = null)
{
state = JsonSerializer.Deserialize<TestRequestAgentThreadState>(serializedState, jsonSerializerOptions)
?? throw new ArgumentException("Unable to deserialize thread state.");
return state.ThreadState;
}
public TestRequestAgentThread(JsonElement element, JsonSerializerOptions? jsonSerializerOptions = null)
: base(DeserializeAndExtractState(element, out TestRequestAgentThreadState state, jsonSerializerOptions))
{
this.UnservicedRequests = state.UnservicedRequests.ToDictionary(
keySelector: item => item.Key,
elementSelector: item => item.Value.As<TRequest>()!);
this.ServicedRequests = state.ServicedRequests;
this.PairedRequests = state.PairedRequests;
}
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
JsonElement threadState = base.Serialize(jsonSerializerOptions);
Dictionary<string, PortableValue> portableUnservicedRequests =
this.UnservicedRequests.ToDictionary(
keySelector: item => item.Key,
elementSelector: item => new PortableValue(item.Value));
TestRequestAgentThreadState state = new(threadState, portableUnservicedRequests, this.ServicedRequests, this.PairedRequests);
return JsonSerializer.SerializeToElement(state, jsonSerializerOptions);
}
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -11,36 +10,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class TestRunContext : IRunnerContext
{
private sealed class TestExternalRequestContext(IRunnerContext runnerContext, string executorId, EdgeMap? map) : IExternalRequestContext
{
public IExternalRequestSink RegisterPort(RequestPort port)
{
if (map?.TryRegisterPort(runnerContext, executorId, port) == false)
{
throw new InvalidOperationException("Duplicate port id: " + port.Id);
}
return runnerContext;
}
}
internal TestRunContext ConfigureExecutor(Executor executor, EdgeMap? map = null)
{
executor.Configure(new TestExternalRequestContext(this, executor.Id, map));
this.Executors.Add(executor.Id, executor);
return this;
}
internal TestRunContext ConfigureExecutors(IEnumerable<Executor> executors, EdgeMap? map = null)
{
foreach (var executor in executors)
{
this.ConfigureExecutor(executor, map);
}
return this;
}
private sealed class BoundContext(
string executorId,
TestRunContext runnerContext,
@@ -88,13 +57,13 @@ public class TestRunContext : IRunnerContext
return default;
}
public IWorkflowContext BindWorkflowContext(string executorId, Dictionary<string, string>? traceContext = null)
public IWorkflowContext Bind(string executorId, Dictionary<string, string>? traceContext = null)
=> new BoundContext(executorId, this, traceContext);
public ConcurrentQueue<ExternalRequest> ExternalRequests { get; } = [];
public List<ExternalRequest> ExternalRequests { get; } = [];
public ValueTask PostAsync(ExternalRequest request)
{
this.ExternalRequests.Enqueue(request);
this.ExternalRequests.Add(request);
return default;
}
@@ -130,8 +99,8 @@ public class TestRunContext : IRunnerContext
public Dictionary<string, Executor> Executors { get; set; } = [];
public string StartingExecutorId { get; set; } = string.Empty;
public bool WithCheckpointing => false;
public bool ConcurrentRunsEnabled => false;
public bool WithCheckpointing => throw new NotSupportedException();
public bool ConcurrentRunsEnabled => throw new NotSupportedException();
ValueTask<Executor> IRunnerContext.EnsureExecutorAsync(string executorId, IStepTracer? tracer, CancellationToken cancellationToken) =>
new(this.Executors[executorId]);
+1 -33
View File
@@ -7,37 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.0.0b260123] - 2026-01-23
### Added
- **agent-framework-azure-ai**: Add support for `rai_config` in agent creation ([#3265](https://github.com/microsoft/agent-framework/pull/3265))
- **agent-framework-azure-ai**: Support reasoning config for `AzureAIClient` ([#3403](https://github.com/microsoft/agent-framework/pull/3403))
- **agent-framework-anthropic**: Add `response_format` support for structured outputs ([#3301](https://github.com/microsoft/agent-framework/pull/3301))
### Changed
- **agent-framework-core**: [BREAKING] Simplify content types to a single class with classmethod constructors ([#3252](https://github.com/microsoft/agent-framework/pull/3252))
- **agent-framework-core**: [BREAKING] Make `response_format` validation errors visible to users ([#3274](https://github.com/microsoft/agent-framework/pull/3274))
- **agent-framework-ag-ui**: [BREAKING] Simplify run logic; fix MCP and Anthropic client issues ([#3322](https://github.com/microsoft/agent-framework/pull/3322))
- **agent-framework-core**: Prefer runtime `kwargs` for `conversation_id` in OpenAI Responses client ([#3312](https://github.com/microsoft/agent-framework/pull/3312))
### Fixed
- **agent-framework-core**: Verify types during checkpoint deserialization to prevent marker spoofing ([#3243](https://github.com/microsoft/agent-framework/pull/3243))
- **agent-framework-core**: Filter internal args when passing kwargs to MCP tools ([#3292](https://github.com/microsoft/agent-framework/pull/3292))
- **agent-framework-core**: Handle anyio cancel scope errors during MCP connection cleanup ([#3277](https://github.com/microsoft/agent-framework/pull/3277))
- **agent-framework-core**: Filter `conversation_id` when passing kwargs to agent as tool ([#3266](https://github.com/microsoft/agent-framework/pull/3266))
- **agent-framework-core**: Fix `use_agent_middleware` calling private `_normalize_messages` ([#3264](https://github.com/microsoft/agent-framework/pull/3264))
- **agent-framework-core**: Add `system_instructions` to ChatClient LLM span tracing ([#3164](https://github.com/microsoft/agent-framework/pull/3164))
- **agent-framework-core**: Fix Azure chat client asynchronous filtering ([#3260](https://github.com/microsoft/agent-framework/pull/3260))
- **agent-framework-core**: Fix `HostedImageGenerationTool` mapping to `ImageGenTool` for Azure AI ([#3263](https://github.com/microsoft/agent-framework/pull/3263))
- **agent-framework-azure-ai**: Fix local MCP tools with `AzureAIProjectAgentProvider` ([#3315](https://github.com/microsoft/agent-framework/pull/3315))
- **agent-framework-azurefunctions**: Fix MCP tool invocation to use the correct agent ([#3339](https://github.com/microsoft/agent-framework/pull/3339))
- **agent-framework-declarative**: Fix MCP tool connection not passed from YAML to Azure AI agent creation API ([#3248](https://github.com/microsoft/agent-framework/pull/3248))
- **agent-framework-ag-ui**: Properly handle JSON serialization with handoff workflows as agent ([#3275](https://github.com/microsoft/agent-framework/pull/3275))
- **agent-framework-devui**: Ensure proper form rendering for `int` ([#3201](https://github.com/microsoft/agent-framework/pull/3201))
## [1.0.0b260116] - 2026-01-16
### Added
@@ -542,8 +511,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.0b260123...HEAD
[1.0.0b260123]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...python-1.0.0b260123
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260116...HEAD
[1.0.0b260116]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260114...python-1.0.0b260116
[1.0.0b260114]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260107...python-1.0.0b260114
[1.0.0b260107]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b260106...python-1.0.0b260107
+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.0b260123"
version = "1.0.0b260116"
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
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260123"
version = "1.0.0b260116"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
+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.0b260123"
version = "1.0.0b260116"
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.0b260123"
version = "1.0.0b260116"
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 = "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.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -3,8 +3,6 @@
import importlib.metadata
from ._app import AgentFunctionApp
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._orchestration import DurableAIAgent
try:
__version__ = importlib.metadata.version(__name__)
@@ -12,9 +10,6 @@ except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"AgentCallbackContext",
"AgentFunctionApp",
"AgentResponseCallbackProtocol",
"DurableAIAgent",
"__version__",
]
@@ -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 ._callbacks import AgentResponseCallbackProtocol
from ._constants import (
from agent_framework_durabletask import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
MIMETYPE_APPLICATION_JSON,
@@ -28,12 +28,17 @@ from ._constants 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 ._models import AgentSessionId, RunRequest
from ._orchestration import AgentOrchestrationContextType, DurableAIAgent
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
logger = get_logger("agent_framework.azurefunctions")
@@ -294,7 +299,7 @@ class AgentFunctionApp(DFAppBase):
self,
context: AgentOrchestrationContextType,
agent_name: str,
) -> DurableAIAgent:
) -> DurableAIAgent[AgentTask]:
"""Return a DurableAIAgent proxy for a registered agent.
Args:
@@ -305,14 +310,15 @@ class AgentFunctionApp(DFAppBase):
ValueError: If the requested agent has not been registered.
Returns:
DurableAIAgent wrapper bound to the orchestration context.
DurableAIAgent[AgentTask] 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.")
return DurableAIAgent(context, normalized_name)
executor = AzureFunctionsAgentExecutor(context)
return DurableAIAgent(executor, normalized_name)
def _setup_agent_functions(
self,
@@ -375,8 +381,6 @@ 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
@@ -385,9 +389,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}")
logger.debug(f"[HTTP Trigger] Thread ID: {thread_id}")
logger.debug(f"[HTTP Trigger] wait_for_response: {wait_for_response}")
logger.debug(
f"[HTTP Trigger] Message: {message}, Thread ID: {thread_id}, wait_for_response: {wait_for_response}"
)
if not message:
logger.warning("[HTTP Trigger] Request rejected: Missing message")
@@ -401,15 +405,18 @@ class AgentFunctionApp(DFAppBase):
session_id = self._create_session_id(agent_name, thread_id)
correlation_id = self._generate_unique_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...")
logger.debug(
f"[HTTP Trigger] Calling entity to run agent using session ID: {session_id} "
f"and correlation ID: {correlation_id}"
)
entity_instance_id = session_id.to_entity_id()
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
run_request = self._build_request_data(
req_body,
message,
thread_id,
correlation_id,
request_response_format,
)
@@ -622,14 +629,16 @@ class AgentFunctionApp(DFAppBase):
session_id = AgentSessionId.with_random_key(agent_name)
# Build entity instance ID
entity_instance_id = session_id.to_entity_id()
entity_instance_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
# 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,
)
@@ -781,7 +790,7 @@ class AgentFunctionApp(DFAppBase):
agent_response = state.try_get_agent_response(correlation_id)
if agent_response:
result = self._build_success_result(
response_data=agent_response,
response_message=agent_response.text,
message=message,
thread_id=thread_id,
correlation_id=correlation_id,
@@ -827,23 +836,22 @@ class AgentFunctionApp(DFAppBase):
)
def _build_success_result(
self, response_data: dict[str, Any], message: str, thread_id: str, correlation_id: str, state: DurableAgentState
self, response_message: str, 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_data.get("content"),
response=response_message,
message=message,
thread_id=thread_id,
status="success",
correlation_id=correlation_id,
extra_fields={"message_count": response_data.get("message_count", state.message_count)},
extra_fields={ApiResponseFields.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]:
@@ -857,8 +865,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]:
@@ -910,15 +918,13 @@ 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, func_name: str, thread_id: str | None) -> AgentSessionId:
def _create_session_id(self, agent_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=func_name, key=thread_id)
return AgentSessionId.with_random_key(name=func_name)
return AgentSessionId(name=agent_name, key=thread_id)
return AgentSessionId.with_random_key(name=agent_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."""
@@ -8,346 +8,41 @@ allows for long-running agent conversations.
"""
import asyncio
import inspect
from collections.abc import AsyncIterable, Callable
from collections.abc import Callable
from typing import Any, cast
import azure.durable_functions as df
from agent_framework import (
AgentProtocol,
AgentResponse,
AgentResponseUpdate,
ChatMessage,
Content,
Role,
get_logger,
from agent_framework import AgentProtocol, get_logger
from agent_framework_durabletask import (
AgentEntity,
AgentEntityStateProviderMixin,
AgentResponseCallbackProtocol,
)
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 AgentEntity:
"""Durable entity that manages agent execution and conversation state.
class AzureFunctionEntityStateProvider(AgentEntityStateProviderMixin):
"""Azure Functions Durable Entity state provider for AgentEntity.
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
This class utilizes the Durable Entity context from `azure-functions-durable` package
to get and set the state of the agent entity.
"""
agent: AgentProtocol
state: DurableAgentState
def __init__(self, context: df.DurableEntityContext) -> None:
self._context = context
def __init__(
self,
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
):
"""Initialize the agent entity.
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)
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 _set_state_dict(self, state: dict[str, Any]) -> None:
self._context.set_state(state)
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,
) -> AgentResponse:
"""(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:
AgentResponse enriched with execution metadata.
"""
return await self.run(context, request)
async def run(
self,
context: df.DurableEntityContext,
request: RunRequest | dict[str, Any] | str,
) -> AgentResponse:
"""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:
AgentResponse 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, "options": {}}
if not enable_tool_calls:
run_kwargs["options"]["tools"] = None
if response_format:
run_kwargs["options"]["response_format"] = response_format
agent_response: AgentResponse = 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_response).__name__,
)
try:
response_text = agent_response.text if agent_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_response)
self.state.data.conversation_history.append(state_response)
logger.debug("[AgentEntity.run] AgentResponse stored in conversation history")
return agent_response
except Exception as exc:
logger.exception("[AgentEntity.run] Agent execution failed.")
# Create error message
error_message = ChatMessage(
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
)
error_response = AgentResponse(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,
) -> AgentResponse:
"""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[AgentResponseUpdate], 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_response = await self._invoke_non_stream(run_kwargs)
await self._notify_final_response(agent_response, callback_context)
return agent_response
async def _consume_stream(
self,
stream: AsyncIterable[AgentResponseUpdate],
callback_context: AgentCallbackContext | None = None,
) -> AgentResponse:
"""Consume streaming responses and build the final AgentResponse."""
updates: list[AgentResponseUpdate] = []
async for update in stream:
updates.append(update)
await self._notify_stream_update(update, callback_context)
if updates:
response = AgentResponse.from_agent_run_response_updates(updates)
else:
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
response = AgentResponse(messages=[])
await self._notify_final_response(response, callback_context)
return response
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentResponse:
"""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, AgentResponse):
raise TypeError(f"Agent run() must return an AgentResponse instance; received {type(result).__name__}")
return result
async def _notify_stream_update(
self,
update: AgentResponseUpdate,
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: AgentResponse,
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 _get_thread_id_from_entity(self) -> str:
return self._context.entity_key
def create_agent_entity(
@@ -368,19 +63,10 @@ def create_agent_entity(
"""Async handler that executes the entity operations."""
try:
logger.debug("[entity_function] Entity triggered")
logger.debug(f"[entity_function] Operation: {context.operation_name}")
logger.debug("[entity_function] Operation: %s", context.operation_name)
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")
state_provider = AzureFunctionEntityStateProvider(context)
entity = AgentEntity(agent, callback, state_provider=state_provider)
operation = context.operation_name
@@ -394,21 +80,18 @@ 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(context, request)
result = await entity.run(request)
context.set_result(result.to_dict())
elif operation == "reset":
entity.reset(context)
entity.reset()
context.set_result({"status": "reset"})
else:
logger.error("[entity_function] Unknown operation: %s", operation)
context.set_result({"error": f"Unknown operation: {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")
logger.info("[entity_function] Operation %s completed successfully", operation)
except Exception as exc:
logger.exception("[entity_function] Error executing entity operation %s", exc)
@@ -5,24 +5,22 @@
This module provides support for using agents inside Durable Function orchestrations.
"""
import uuid
from collections.abc import AsyncIterator, Callable, Sequence
from typing import TYPE_CHECKING, Any, TypeAlias, cast
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, TypeAlias
from agent_framework import (
AgentProtocol,
AgentResponse,
AgentResponseUpdate,
AgentThread,
ChatMessage,
get_logger,
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 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
@@ -45,6 +43,25 @@ 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 AgentResponse results.
@@ -65,10 +82,13 @@ class AgentTask(_TypedCompoundTask):
response_format: Optional Pydantic model for response parsing
correlation_id: Correlation ID for logging
"""
super().__init__([entity_task])
# Set instance variables BEFORE calling super().__init__
# because super().__init__ may trigger try_set_value for pre-completed tasks
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
@@ -95,10 +115,10 @@ class AgentTask(_TypedCompoundTask):
)
try:
response = self._load_agent_response(raw_result)
response = load_agent_response(raw_result)
if self._response_format is not None:
self._ensure_response_format(
ensure_response_format(
self._response_format,
self._correlation_id,
response,
@@ -118,230 +138,84 @@ 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: AgentResponse | dict[str, Any] | None) -> AgentResponse:
"""Convert raw payloads into AgentResponse instance."""
if agent_response is None:
raise ValueError("agent_response cannot be None")
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
class AzureFunctionsAgentExecutor(DurableAgentExecutor[AgentTask]):
"""Executor that executes durable agents inside Azure Functions orchestrations."""
if isinstance(agent_response, AgentResponse):
return agent_response
if isinstance(agent_response, dict):
logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict")
return AgentResponse.from_dict(agent_response)
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
def _ensure_response_format(
self,
response_format: type[BaseModel] | None,
correlation_id: str,
response: AgentResponse,
) -> None:
"""Ensure the AgentResponse 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 AgentResponse.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:
context: The orchestration context
agent_name: Name of the agent (used to construct entity ID)
"""
def __init__(self, context: AgentOrchestrationContextType):
self.context = context
self.agent_name = agent_name
self.id = str(uuid.uuid4())
self.name = agent_name
self.description = f"Durable agent proxy for {agent_name}"
logger.debug("[DurableAIAgent] Initialized for agent: %s", agent_name)
# 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 AgentResponse result.
def run( # type: ignore[override]
def generate_unique_id(self) -> str:
return str(self.context.new_uuid())
def get_run_request(
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
message: str,
*,
thread: AgentThread | None = None,
options: dict[str, Any] | 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 AgentResponse.
) -> RunRequest:
"""Get the current run request from the orchestration context.
Args:
messages: The message(s) to send to the agent
thread: Optional agent thread for conversation context
options: Optional dict containing chat options like response_format, tools, etc.
**kwargs: Additional arguments (enable_tool_calls)
message: The message to send to the agent
options: Optional options dictionary. Supported keys include
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
Additional keys are forwarded to the agent execution.
Returns:
An AgentTask that resolves to an AgentResponse when yielded
RunRequest: The current run request
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, options={"response_format": MyModel})
# response is typed as AgentResponse
Raises:
ValueError: If wait_for_response=False (not supported in orchestrations)
"""
message_str = self._normalize_messages(messages)
# Create a copy to avoid modifying the caller's dict
# Extract options from the options dict (aligned with ChatAgent pattern)
opts = options or {}
response_format: type[BaseModel] | None = opts.get("response_format")
enable_tool_calls = opts.get("enable_tool_calls", kwargs.get("enable_tool_calls", True))
request = super().get_run_request(message, options=options)
request.orchestration_id = self.context.instance_id
return request
# 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)
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> AgentTask:
# Create entity ID from session ID
entity_id = session_id.to_entity_id()
# Resolve session
session_id = self._create_session_id(agent_name, thread)
entity_id = df.EntityId(
name=session_id.entity_name,
key=session_id.key,
)
# 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(
"[DurableAIAgent] Using correlation_id: %s for entity_id: %s for session_id: %s",
correlation_id,
"[AzureFunctionsAgentProvider] correlation_id: %s entity_id: %s session_id: %s",
run_request.correlation_id,
entity_id,
session_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,
)
# 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())
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
# Create acceptance response using base class helper
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
# Call the entity to get the underlying task
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
# 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())
# Wrap it in an AgentTask that will convert the result to AgentResponse
agent_task = AgentTask(
return AgentTask(
entity_task=entity_task,
response_format=response_format,
correlation_id=correlation_id,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
)
logger.debug(
"[DurableAIAgent] Created AgentTask for correlation_id %s",
correlation_id,
)
return agent_task
def run_stream(
self,
messages: str | ChatMessage | Sequence[str | ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterator[AgentResponseUpdate]:
"""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 | Sequence[str | 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.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,6 +23,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core",
"agent-framework-durabletask",
"azure-functions",
"azure-functions-durable",
]
@@ -15,10 +15,9 @@ Usage:
"""
import pytest
from agent_framework_durabletask import THREAD_ID_HEADER
from testutils import SampleTestHelper, skip_if_azure_functions_integration_tests_disabled
from agent_framework_azurefunctions._constants import THREAD_ID_HEADER
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("01_single_agent"),
@@ -2,6 +2,8 @@
"""Unit tests for AgentFunctionApp."""
# pyright: reportPrivateUsage=false
import json
from collections.abc import Awaitable, Callable
from typing import Any, TypeVar
@@ -11,20 +13,42 @@ import azure.durable_functions as df
import azure.functions as func
import pytest
from agent_framework import AgentResponse, ChatMessage
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 (
from agent_framework_durabletask 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._durable_agent_state import DurableAgentState
from agent_framework_azurefunctions._entities import AgentEntity, create_agent_entity
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._entities import 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."""
@@ -88,7 +112,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
@@ -104,7 +128,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
@@ -119,7 +143,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
@@ -335,13 +359,12 @@ class TestAgentEntityOperations:
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Test response")])
)
entity = AgentEntity(mock_agent)
mock_context = Mock()
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="test-conv-123"))
result = await entity.run(
mock_context,
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
)
result = await entity.run({
"message": "Test message",
"correlationId": "corr-app-entity-1",
})
assert isinstance(result, AgentResponse)
assert result.text == "Test response"
@@ -354,22 +377,17 @@ class TestAgentEntityOperations:
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response 1")])
)
entity = AgentEntity(mock_agent)
mock_context = Mock()
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
# Send first message
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
)
await entity.run({"message": "Message 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(
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
)
await entity.run({"message": "Message 2", "correlationId": "corr-app-entity-2b"})
# Now we have 4 entries total (2 requests + 2 responses)
# Access the first request entry
@@ -393,32 +411,26 @@ class TestAgentEntityOperations:
return_value=AgentResponse(messages=[ChatMessage(role="assistant", text="Response")])
)
entity = AgentEntity(mock_agent)
mock_context = Mock()
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
assert len(entity.state.data.conversation_history) == 0
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
)
await entity.run({"message": "Message 1", "correlationId": "corr-app-entity-3a"})
assert len(entity.state.data.conversation_history) == 2
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
)
await entity.run({"message": "Message 2", "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)
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider())
# Set some state
entity.state = DurableAgentState()
# Reset
mock_context = Mock()
entity.reset(mock_context)
entity.reset()
assert len(entity.state.data.conversation_history) == 0
@@ -447,7 +459,6 @@ 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
@@ -475,7 +486,6 @@ 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
@@ -595,7 +605,11 @@ class TestAgentEntityFactory:
}
mock_context = Mock()
mock_context.operation_name = "reset"
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"correlationId": "corr-restore-1",
}
mock_context.get_state.return_value = existing_state
with patch.object(DurableAgentState, "from_dict", wraps=DurableAgentState.from_dict) as from_dict_mock:
@@ -612,12 +626,12 @@ class TestErrorHandling:
mock_agent = Mock()
mock_agent.run = AsyncMock(side_effect=Exception("Agent error"))
entity = AgentEntity(mock_agent)
mock_context = Mock()
entity = AgentEntity(mock_agent, state_provider=_InMemoryStateProvider(thread_id="conv-1"))
result = await entity.run(
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
)
result = await entity.run({
"message": "Test message",
"correlationId": "corr-app-error-1",
})
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
@@ -636,6 +650,7 @@ class TestErrorHandling:
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.operation_name = "run"
mock_context.get_input.side_effect = Exception("Input error")
mock_context.get_state.return_value = None
@@ -710,7 +725,7 @@ class TestIncomingRequestParsing:
request = Mock()
request.params = {"thread_id": "query-thread"}
req_body = {}
req_body: dict[str, Any] = {}
thread_id = app._resolve_thread_id(request, req_body)
@@ -777,7 +792,7 @@ class TestHttpRunRoute:
assert run_request["message"] == "Plain text via HTTP"
assert run_request["role"] == "user"
assert "thread_id" in run_request
assert "thread_id" not in run_request
async def test_http_run_accept_header_returns_json(self) -> None:
"""Test that Accept header requesting JSON results in JSON response."""
@@ -913,9 +928,9 @@ class TestMCPToolEndpoint:
patch.object(app, "durable_client_input") as client_mock,
):
# Setup mock decorator chain
func_name_mock.return_value = lambda f: f
mcp_trigger_mock.return_value = lambda f: f
client_mock.return_value = lambda f: f
func_name_mock.return_value = _identity_decorator
mcp_trigger_mock.return_value = _identity_decorator
client_mock.return_value = _identity_decorator
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
@@ -938,11 +953,11 @@ class TestMCPToolEndpoint:
app = AgentFunctionApp()
with (
patch.object(app, "function_name", return_value=lambda f: f),
patch.object(app, "function_name", return_value=_identity_decorator),
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
patch.object(app, "durable_client_input", return_value=lambda f: f),
patch.object(app, "durable_client_input", return_value=_identity_decorator),
):
mcp_trigger_mock.return_value = lambda f: f
mcp_trigger_mock.return_value = _identity_decorator
app._setup_mcp_tool_trigger(mock_agent.name, None)
@@ -1128,10 +1143,10 @@ class TestMCPToolEndpoint:
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
# Capture the health check handler function
captured_handler = None
captured_handler: Callable[[func.HttpRequest], func.HttpResponse] | None = None
def capture_decorator(*args, **kwargs):
def decorator(func):
def capture_decorator(*args: Any, **kwargs: Any) -> Callable[[TFunc], TFunc]:
def decorator(func: TFunc) -> TFunc:
nonlocal captured_handler
captured_handler = func
return func
@@ -1,423 +1,32 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for AgentEntity and entity operations.
"""Unit tests for create_agent_entity factory function.
Run with: pytest tests/test_entities.py -v
"""
import asyncio
from collections.abc import AsyncIterator, Callable
from datetime import datetime
from collections.abc import Callable
from typing import Any, TypeVar
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import AsyncMock, Mock
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Role
from pydantic import BaseModel
from agent_framework import AgentResponse, ChatMessage, Role
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
from agent_framework_azurefunctions._entities import create_agent_entity
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) -> AgentResponse:
"""Create an AgentResponse with a single assistant message."""
message = (
ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[])
ChatMessage(role=Role.ASSISTANT, text=text)
if text is not None
else ChatMessage(role=Role.ASSISTANT, contents=[])
)
return AgentResponse(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: AgentResponseUpdate,
context: Any,
) -> None:
await self.stream_mock(update, context)
async def on_agent_response(self, response: AgentResponse, 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, AgentResponse)
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, AgentResponse)
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 = [
AgentResponseUpdate(text="Hello"),
AgentResponseUpdate(text=" world"),
]
async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
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, AgentResponse)
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, AgentResponse)
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."""
@@ -439,9 +48,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
@@ -535,7 +144,7 @@ class TestCreateAgentEntity:
assert state["data"] == {"conversationHistory": []}
def test_entity_function_restores_existing_state(self) -> None:
"""Test that the entity function restores existing state."""
"""Test that the entity function can operate when existing state is present."""
mock_agent = Mock()
entity_function = create_agent_entity(mock_agent)
@@ -584,482 +193,14 @@ 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, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert content.type == "error"
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, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert content.type == "error"
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, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert content.type == "error"
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
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, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert content.type == "error"
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, AgentResponse)
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, AgentResponse)
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, AgentResponse)
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, AgentResponse)
# 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
# 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"] == []
if __name__ == "__main__":
@@ -1,447 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""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_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_parse_with_agent_name_override(self) -> None:
"""Test parsing @name@key format with agent_name parameter overrides the name."""
session_id = AgentSessionId.parse("@OriginalAgent@test-key-123", agent_name="OverriddenAgent")
assert session_id.name == "OverriddenAgent"
assert session_id.key == "test-key-123"
def test_parse_without_agent_name_uses_parsed_name(self) -> None:
"""Test parsing @name@key format without agent_name uses name from string."""
session_id = AgentSessionId.parse("@ParsedAgent@test-key-123")
assert session_id.name == "ParsedAgent"
assert session_id.key == "test-key-123"
def test_parse_plain_string_with_agent_name(self) -> None:
"""Test parsing plain string with agent_name uses entire string as key."""
session_id = AgentSessionId.parse("simple-thread-123", agent_name="TestAgent")
assert session_id.name == "TestAgent"
assert session_id.key == "simple-thread-123"
def test_parse_plain_string_without_agent_name_raises(self) -> None:
"""Test parsing plain string without agent_name raises ValueError."""
with pytest.raises(ValueError) as exc_info:
AgentSessionId.parse("simple-thread-123")
assert "Invalid agent session ID format" in str(exc_info.value)
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", thread_id="thread-default")
assert request.message == "Hello"
assert request.role == Role.USER
assert request.response_format is None
assert request.enable_tool_calls 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",
thread_id="thread-123",
role=Role.SYSTEM,
response_format=schema,
enable_tool_calls=False,
)
assert request.message == "Hello"
assert request.role == Role.SYSTEM
assert request.response_format is schema
assert request.enable_tool_calls 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", 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", thread_id="thread-to-dict")
data = request.to_dict()
assert data["message"] == "Test message"
assert data["enable_tool_calls"] is True
assert data["role"] == "user"
assert "response_format" not in data or data["response_format"] is None
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",
thread_id="thread-456",
role=Role.ASSISTANT,
response_format=schema,
enable_tool_calls=False,
)
data = request.to_dict()
assert data["message"] == "Hello"
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["thread_id"] == "thread-456"
def test_from_dict_with_defaults(self) -> None:
"""Test from_dict with minimal data."""
data = {"message": "Hello", "thread_id": "thread-from-dict"}
request = RunRequest.from_dict(data)
assert request.message == "Hello"
assert request.role == Role.USER
assert request.enable_tool_calls is True
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",
"role": "system",
"response_format": {
"__response_schema_type__": "pydantic_model",
"module": ModuleStructuredResponse.__module__,
"qualname": ModuleStructuredResponse.__qualname__,
},
"enable_tool_calls": False,
"thread_id": "thread-789",
}
request = RunRequest.from_dict(data)
assert request.message == "Test"
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_with_unknown_role_preserves_value(self) -> None:
"""Test from_dict keeps custom roles intact."""
data = {"message": "Test", "role": "reviewer", "thread_id": "thread-with-custom-role"}
request = RunRequest.from_dict(data)
assert request.role.value == "reviewer"
assert request.role != Role.USER
def test_from_dict_empty_message(self) -> None:
"""Test from_dict with empty message."""
data = {"thread_id": "thread-empty"}
request = RunRequest.from_dict(data)
assert request.message == ""
assert request.role == Role.USER
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",
thread_id="thread-123",
role=Role.SYSTEM,
response_format=ModuleStructuredResponse,
enable_tool_calls=False,
)
data = original.to_dict()
restored = RunRequest.from_dict(data)
assert restored.message == original.message
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",
thread_id="thread-pydantic",
response_format=ModuleStructuredResponse,
)
data = original.to_dict()
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
restored = RunRequest.from_dict(data)
assert restored.response_format is ModuleStructuredResponse
def test_init_with_correlationId(self) -> None:
"""Test RunRequest initialization with correlationId."""
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", thread_id="thread-corr-to-dict", correlation_id="corr-456")
data = request.to_dict()
assert data["message"] == "Test"
assert data["correlationId"] == "corr-456"
def test_from_dict_with_correlationId(self) -> None:
"""Test from_dict with correlationId."""
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-123",
)
data = original.to_dict()
restored = RunRequest.from_dict(data)
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",
thread_id="thread-orch-init",
orchestration_id="orch-123",
)
assert request.message == "Test message"
assert request.orchestration_id == "orch-123"
def test_to_dict_with_orchestration_id(self) -> None:
"""Test to_dict includes orchestrationId."""
request = RunRequest(
message="Test",
thread_id="thread-orch-to-dict",
orchestration_id="orch-456",
)
data = request.to_dict()
assert data["message"] == "Test"
assert data["orchestrationId"] == "orch-456"
def test_to_dict_excludes_orchestration_id_when_none(self) -> None:
"""Test to_dict excludes orchestrationId when not set."""
request = RunRequest(
message="Test",
thread_id="thread-orch-none",
)
data = request.to_dict()
assert "orchestrationId" not in data
def test_from_dict_with_orchestration_id(self) -> None:
"""Test from_dict with orchestrationId."""
data = {
"message": "Test",
"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-123",
orchestration_id="orch-123",
)
data = original.to_dict()
restored = RunRequest.from_dict(data)
assert restored.message == original.message
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__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -6,11 +6,11 @@ from typing import Any
from unittest.mock import Mock
import pytest
from agent_framework import AgentResponse, AgentThread, ChatMessage
from agent_framework import AgentResponse, ChatMessage, Role
from agent_framework_durabletask import DurableAIAgent
from azure.durable_functions.models.Task import TaskBase, TaskState
from agent_framework_azurefunctions import AgentFunctionApp, DurableAIAgent
from agent_framework_azurefunctions._models import AgentSessionId, DurableAgentThread
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_azurefunctions._orchestration import AgentTask
@@ -38,46 +38,96 @@ 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 helper utilities that prepare AgentResponse 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 = AgentResponse(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 = AgentResponse(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_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]
"""Tests for response handling through public AgentTask API."""
def test_try_set_value_success(self) -> None:
"""Test try_set_value correctly processes successful task completion."""
@@ -142,334 +192,10 @@ 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 = AgentResponse(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 = AgentResponse(
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")
# Verify values
assert agent.name == "TestAgent"
assert agent.description == "Durable agent proxy for 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, options={"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")
@@ -478,18 +204,87 @@ 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, options={"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, options={"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, options={"wait_for_response": False})
# Get the result
response = result.result
assert isinstance(response, AgentResponse)
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, options={"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) -> None:
def test_sequential_agent_calls_simulation(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
"""Simulate sequential agent calls in an orchestration."""
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"])
executor, context, uuid_hexes = executor_with_multiple_uuids
# Track entity calls
entity_calls: list[dict[str, Any]] = []
@@ -498,10 +293,10 @@ class TestOrchestrationIntegration:
entity_calls.append({"entity_id": str(entity_id), "operation": operation, "input": input_data})
return _create_entity_task()
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
app = _app_with_registered_agents("WriterAgent")
agent = app.get_agent(mock_context, "WriterAgent")
# Create agent directly with executor (not via app.get_agent)
agent = DurableAIAgent(executor, "WriterAgent")
# Create thread
thread = agent.get_new_thread()
@@ -517,18 +312,15 @@ 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@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
# 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
def test_multiple_agents_in_orchestration(self) -> None:
def test_multiple_agents_in_orchestration(self, executor_with_multiple_uuids: tuple[Any, Mock, list[str]]) -> None:
"""Test using multiple different agents in one orchestration."""
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"])
executor, context, uuid_hexes = executor_with_multiple_uuids
entity_calls: list[str] = []
@@ -536,11 +328,11 @@ class TestOrchestrationIntegration:
entity_calls.append(str(entity_id))
return _create_entity_task()
mock_context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
context.call_entity = Mock(side_effect=mock_call_entity_side_effect)
app = _app_with_registered_agents("WriterAgent", "EditorAgent")
writer = app.get_agent(mock_context, "WriterAgent")
editor = app.get_agent(mock_context, "EditorAgent")
# Create agents directly with executor (not via app.get_agent)
writer = DurableAIAgent(executor, "WriterAgent")
editor = DurableAIAgent(executor, "EditorAgent")
writer_thread = writer.get_new_thread()
editor_thread = editor.get_new_thread()
@@ -554,62 +346,11 @@ class TestOrchestrationIntegration:
# Verify different entity IDs were used
assert len(entity_calls) == 2
# 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
# 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
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.0b260123"
version = "1.0.0b260116"
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.0b260123"
version = "1.0.0b260116"
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.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -4,9 +4,9 @@ import importlib
from typing import Any
_IMPORTS: dict[str, tuple[str, str]] = {
"AgentCallbackContext": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"AgentCallbackContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
"AgentFunctionApp": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"AgentResponseCallbackProtocol": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"AgentResponseCallbackProtocol": ("agent_framework_durabletask", "agent-framework-durabletask"),
"AzureAIAgentClient": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureAIAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
"AzureAIProjectAgentOptions": ("agent_framework_azure_ai", "agent-framework-azure-ai"),
@@ -24,7 +24,10 @@ _IMPORTS: dict[str, tuple[str, str]] = {
"AzureOpenAIResponsesOptions": ("agent_framework.azure._responses_client", "agent-framework-core"),
"AzureOpenAISettings": ("agent_framework.azure._shared", "agent-framework-core"),
"AzureUserSecurityContext": ("agent_framework.azure._chat_client", "agent-framework-core"),
"DurableAIAgent": ("agent_framework_azurefunctions", "agent-framework-azurefunctions"),
"DurableAIAgent": ("agent_framework_durabletask", "agent-framework-durabletask"),
"DurableAIAgentClient": ("agent_framework_durabletask", "agent-framework-durabletask"),
"DurableAIAgentOrchestrationContext": ("agent_framework_durabletask", "agent-framework-durabletask"),
"DurableAIAgentWorker": ("agent_framework_durabletask", "agent-framework-durabletask"),
"get_entra_auth_token": ("agent_framework.azure._entra_id_authentication", "agent-framework-core"),
}
@@ -9,11 +9,14 @@ from agent_framework_azure_ai import (
AzureAISettings,
)
from agent_framework_azure_ai_search import AzureAISearchContextProvider, AzureAISearchSettings
from agent_framework_azurefunctions import (
from agent_framework_azurefunctions import AgentFunctionApp
from agent_framework_durabletask import (
AgentCallbackContext,
AgentFunctionApp,
AgentResponseCallbackProtocol,
DurableAIAgent,
DurableAIAgentClient,
DurableAIAgentOrchestrationContext,
DurableAIAgentWorker,
)
from agent_framework.azure._assistants_client import AzureOpenAIAssistantsClient
@@ -39,5 +42,8 @@ __all__ = [
"AzureOpenAIResponsesClient",
"AzureOpenAISettings",
"DurableAIAgent",
"DurableAIAgentClient",
"DurableAIAgentOrchestrationContext",
"DurableAIAgentWorker",
"get_entra_auth_token",
]
+2 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -50,6 +50,7 @@ all = [
"agent-framework-copilotstudio",
"agent-framework-declarative",
"agent-framework-devui",
"agent-framework-durabletask",
"agent-framework-lab",
"agent-framework-mem0",
"agent-framework-ollama",
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260123"
version = "1.0.0b260116"
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 = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+31
View File
@@ -0,0 +1,31 @@
# Get Started with Microsoft Agent Framework Durable Task
[![PyPI](https://img.shields.io/pypi/v/agent-framework-durabletask)](https://pypi.org/project/agent-framework-durabletask/)
Please install this package via pip:
```bash
pip install agent-framework-durabletask --pre
```
## Durable Task Integration
The durable task integration lets you host Microsoft Agent Framework agents using the [Durable Task](https://github.com/microsoft/durabletask-python) framework so they can persist state, replay conversation history, and recover from failures automatically.
### Basic Usage Example
```python
from durabletask import TaskHubGrpcWorker
from agent_framework.azure import DurableAIAgentWorker
# Create the worker
with DurableTaskSchedulerWorker(...) as worker:
# Register the agent worker wrapper
agent_worker = DurableAIAgentWorker(worker)
# Register the agent
agent_worker.add_agent(my_agent)
```
For more details, review the Python [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) and the samples directory.
@@ -0,0 +1,108 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Task integration for Microsoft Agent Framework."""
import importlib.metadata
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._client import DurableAIAgentClient
from ._constants import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
MIMETYPE_APPLICATION_JSON,
MIMETYPE_TEXT_PLAIN,
REQUEST_RESPONSE_FORMAT_JSON,
REQUEST_RESPONSE_FORMAT_TEXT,
THREAD_ID_FIELD,
THREAD_ID_HEADER,
WAIT_FOR_RESPONSE_FIELD,
WAIT_FOR_RESPONSE_HEADER,
ApiResponseFields,
ContentTypes,
DurableStateFields,
)
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateContent,
DurableAgentStateData,
DurableAgentStateDataContent,
DurableAgentStateEntry,
DurableAgentStateEntryJsonType,
DurableAgentStateErrorContent,
DurableAgentStateFunctionCallContent,
DurableAgentStateFunctionResultContent,
DurableAgentStateHostedFileContent,
DurableAgentStateHostedVectorStoreContent,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateResponse,
DurableAgentStateTextContent,
DurableAgentStateTextReasoningContent,
DurableAgentStateUnknownContent,
DurableAgentStateUriContent,
DurableAgentStateUsage,
DurableAgentStateUsageContent,
)
from ._entities import AgentEntity, AgentEntityStateProviderMixin
from ._executors import DurableAgentExecutor
from ._models import AgentSessionId, DurableAgentThread, RunRequest
from ._orchestration_context import DurableAIAgentOrchestrationContext
from ._response_utils import ensure_response_format, load_agent_response
from ._shim import DurableAIAgent
from ._worker import DurableAIAgentWorker
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0" # Fallback for development mode
__all__ = [
"DEFAULT_MAX_POLL_RETRIES",
"DEFAULT_POLL_INTERVAL_SECONDS",
"MIMETYPE_APPLICATION_JSON",
"MIMETYPE_TEXT_PLAIN",
"REQUEST_RESPONSE_FORMAT_JSON",
"REQUEST_RESPONSE_FORMAT_TEXT",
"THREAD_ID_FIELD",
"THREAD_ID_HEADER",
"WAIT_FOR_RESPONSE_FIELD",
"WAIT_FOR_RESPONSE_HEADER",
"AgentCallbackContext",
"AgentEntity",
"AgentEntityStateProviderMixin",
"AgentResponseCallbackProtocol",
"AgentSessionId",
"ApiResponseFields",
"ContentTypes",
"DurableAIAgent",
"DurableAIAgentClient",
"DurableAIAgentOrchestrationContext",
"DurableAIAgentWorker",
"DurableAgentExecutor",
"DurableAgentState",
"DurableAgentStateContent",
"DurableAgentStateData",
"DurableAgentStateDataContent",
"DurableAgentStateEntry",
"DurableAgentStateEntryJsonType",
"DurableAgentStateErrorContent",
"DurableAgentStateFunctionCallContent",
"DurableAgentStateFunctionResultContent",
"DurableAgentStateHostedFileContent",
"DurableAgentStateHostedVectorStoreContent",
"DurableAgentStateMessage",
"DurableAgentStateRequest",
"DurableAgentStateResponse",
"DurableAgentStateTextContent",
"DurableAgentStateTextReasoningContent",
"DurableAgentStateUnknownContent",
"DurableAgentStateUriContent",
"DurableAgentStateUsage",
"DurableAgentStateUsageContent",
"DurableAgentThread",
"DurableStateFields",
"RunRequest",
"__version__",
"ensure_response_format",
"load_agent_response",
]
@@ -0,0 +1,90 @@
# Copyright (c) Microsoft. All rights reserved.
"""Client wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentClient class for external clients to interact
with durable agents via gRPC.
"""
from __future__ import annotations
from agent_framework import AgentResponse, get_logger
from durabletask.client import TaskHubGrpcClient
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from ._executors import ClientAgentExecutor
from ._shim import DurableAgentProvider, DurableAIAgent
logger = get_logger("agent_framework.durabletask.client")
class DurableAIAgentClient(DurableAgentProvider[AgentResponse]):
"""Client wrapper for interacting with durable agents externally.
This class wraps a durabletask TaskHubGrpcClient and provides a convenient
interface for retrieving and executing durable agents from external contexts.
Example:
```python
from durabletask import TaskHubGrpcClient
from agent_framework.azure import DurableAIAgentClient
# Create the underlying client
client = TaskHubGrpcClient(host_address="localhost:4001")
# Wrap it with the agent client
agent_client = DurableAIAgentClient(client)
# Get an agent reference
agent = agent_client.get_agent("assistant")
# Run the agent (synchronous call that waits for completion)
response = agent.run("Hello, how are you?")
print(response.text)
```
"""
def __init__(
self,
client: TaskHubGrpcClient,
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
):
"""Initialize the client wrapper.
Args:
client: The durabletask client instance to wrap
max_poll_retries: Maximum polling attempts when waiting for responses
poll_interval_seconds: Delay in seconds between polling attempts
"""
self._client = client
# Validate and set polling parameters
self.max_poll_retries = max(1, max_poll_retries)
self.poll_interval_seconds = (
poll_interval_seconds if poll_interval_seconds > 0 else DEFAULT_POLL_INTERVAL_SECONDS
)
self._executor = ClientAgentExecutor(self._client, self.max_poll_retries, self.poll_interval_seconds)
logger.debug("[DurableAIAgentClient] Initialized with client type: %s", type(client).__name__)
def get_agent(self, agent_name: str) -> DurableAIAgent[AgentResponse]:
"""Retrieve a DurableAIAgent shim for the specified agent.
This method returns a proxy object that can be used to execute the agent.
The actual agent must be registered on a worker with the same name.
Args:
agent_name: Name of the agent to retrieve (without the dafx- prefix)
Returns:
DurableAIAgent instance that can be used to run the agent
Note:
This method does not validate that the agent exists. Validation
will occur when the agent is executed. If the entity doesn't exist,
the execution will fail with an appropriate error.
"""
logger.debug("[DurableAIAgentClient] Creating agent proxy for: %s", agent_name)
return DurableAIAgent(self._executor, agent_name)
@@ -30,9 +30,10 @@ All classes support bidirectional conversion between:
from __future__ import annotations
import json
from collections.abc import MutableMapping
from datetime import datetime, timezone
from enum import Enum
from typing import Any, cast
from typing import Any, ClassVar, cast
from agent_framework import (
AgentResponse,
@@ -43,10 +44,10 @@ from agent_framework import (
)
from dateutil import parser as date_parser
from ._constants import ApiResponseFields, ContentTypes, DurableStateFields
from ._constants import ContentTypes, DurableStateFields
from ._models import RunRequest, serialize_response_format
logger = get_logger("agent_framework.azurefunctions.durable_agent_state")
logger = get_logger("agent_framework.durabletask.durable_agent_state")
class DurableAgentStateEntryJsonType(str, Enum):
@@ -72,7 +73,10 @@ def _parse_created_at(value: Any) -> datetime:
except (ValueError, TypeError):
pass
logger.warning("Invalid or missing created_at value in durable agent state; defaulting to current UTC time.")
logger.warning(
f"Invalid or missing created_at value in durable agent state; defaulting to current UTC time, {value}",
stack_info=True,
)
return datetime.now(tz=timezone.utc)
@@ -258,7 +262,7 @@ class DurableAgentStateContent:
"""Convert this durable state content back to an agent framework content object.
Returns:
An agent framework content object (TextContent, FunctionCallContent, etc.)
An agent framework content object (Content of type `text`, `function_call`, etc.)
Raises:
NotImplementedError: Must be implemented by subclasses
@@ -269,38 +273,42 @@ class DurableAgentStateContent:
def from_ai_content(content: Any) -> DurableAgentStateContent:
"""Create a durable state content object from an agent framework content object.
This factory method maps agent framework content types (TextContent, FunctionCallContent,
etc.) to their corresponding durable state representations. Unknown content types are
wrapped in DurableAgentStateUnknownContent.
This factory method maps agent framework content types to their corresponding durable state representations.
Unknown content types are wrapped in DurableAgentStateUnknownContent.
Args:
content: An agent framework content object (TextContent, FunctionCallContent, etc.)
content: An agent framework content object (Content of type `text`, `function_call`, etc.)
Returns:
The corresponding DurableAgentStateContent subclass instance
"""
# Map AI content type to appropriate DurableAgentStateContent subclass
if isinstance(content, Content) and content.type == "data":
return DurableAgentStateDataContent.from_data_content(content)
if isinstance(content, Content) and content.type == "error":
return DurableAgentStateErrorContent.from_error_content(content)
if isinstance(content, Content) and content.type == "function_call":
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
if isinstance(content, Content) and content.type == "function_result":
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
if isinstance(content, Content) and content.type == "hosted_file":
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
if isinstance(content, Content) and content.type == "hosted_vector_store":
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
if isinstance(content, Content) and content.type == "text":
return DurableAgentStateTextContent.from_text_content(content)
if isinstance(content, Content) and content.type == "text_reasoning":
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
if isinstance(content, Content) and content.type == "uri":
return DurableAgentStateUriContent.from_uri_content(content)
if isinstance(content, Content) and content.type == "usage":
return DurableAgentStateUsageContent.from_usage_content(content)
return DurableAgentStateUnknownContent.from_unknown_content(content)
if not isinstance(content, Content):
return DurableAgentStateUnknownContent.from_unknown_content(content)
match content.type:
case "data":
return DurableAgentStateDataContent.from_data_content(content)
case "error":
return DurableAgentStateErrorContent.from_error_content(content)
case "function_call":
return DurableAgentStateFunctionCallContent.from_function_call_content(content)
case "function_result":
return DurableAgentStateFunctionResultContent.from_function_result_content(content)
case "hosted_file":
return DurableAgentStateHostedFileContent.from_hosted_file_content(content)
case "hosted_vector_store":
return DurableAgentStateHostedVectorStoreContent.from_hosted_vector_store_content(content)
case "text":
return DurableAgentStateTextContent.from_text_content(content)
case "reasoning":
return DurableAgentStateTextReasoningContent.from_text_reasoning_content(content)
case "uri":
return DurableAgentStateUriContent.from_uri_content(content)
case "usage":
return DurableAgentStateUsageContent.from_usage_content(content)
case _:
return DurableAgentStateUnknownContent.from_unknown_content(content)
# Core state classes
@@ -439,7 +447,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) -> dict[str, Any] | None:
def try_get_agent_response(self, correlation_id: str) -> AgentResponse | None:
"""Try to get an agent response by correlation ID.
This method searches the conversation history for a response entry matching the given
@@ -461,14 +469,8 @@ 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
# Get the text content from assistant messages only
content = "\n".join(message.text for message in entry.messages if message.text)
return DurableAgentStateResponse.to_run_response(entry)
return {
ApiResponseFields.CONTENT: content,
ApiResponseFields.MESSAGE_COUNT: self.message_count,
ApiResponseFields.CORRELATION_ID: correlation_id,
}
return None
@@ -689,7 +691,22 @@ class DurableAgentStateResponse(DurableAgentStateEntry):
correlation_id=correlation_id,
created_at=_parse_created_at(response.created_at),
messages=[DurableAgentStateMessage.from_chat_message(m) for m in response.messages],
usage=DurableAgentStateUsage.from_usage(response.usage_details), # type: ignore[arg-type]
usage=DurableAgentStateUsage.from_usage(response.usage_details),
)
@staticmethod
def to_run_response(
response_entry: DurableAgentStateResponse,
) -> AgentResponse:
"""Converts a DurableAgentStateResponse back to an AgentResponse."""
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 AgentResponse(
created_at=response_entry.created_at.isoformat(),
messages=messages,
usage_details=usage_details,
)
@@ -859,7 +876,9 @@ class DurableAgentStateDataContent(DurableAgentStateContent):
@staticmethod
def from_data_content(content: Content) -> DurableAgentStateDataContent:
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
if content.uri is None:
raise ValueError("uri is required for data content")
return DurableAgentStateDataContent(uri=content.uri, media_type=content.media_type)
def to_ai_content(self) -> Content:
return Content.from_uri(uri=self.uri, media_type=self.media_type)
@@ -940,6 +959,10 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
@staticmethod
def from_function_call_content(content: Content) -> DurableAgentStateFunctionCallContent:
if content.call_id is None:
raise ValueError("call_id is required for function call content")
if content.name is None:
raise ValueError("name is required for function call content")
# Ensure arguments is a dict; parse string if needed
arguments: dict[str, Any] = {}
if content.arguments:
@@ -952,7 +975,7 @@ class DurableAgentStateFunctionCallContent(DurableAgentStateContent):
except json.JSONDecodeError:
arguments = {}
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments) # type: ignore[arg-type]
return DurableAgentStateFunctionCallContent(call_id=content.call_id, name=content.name, arguments=arguments)
def to_ai_content(self) -> Content:
return Content.from_function_call(call_id=self.call_id, name=self.name, arguments=self.arguments)
@@ -988,7 +1011,9 @@ class DurableAgentStateFunctionResultContent(DurableAgentStateContent):
@staticmethod
def from_function_result_content(content: Content) -> DurableAgentStateFunctionResultContent:
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result) # type: ignore[arg-type]
if content.call_id is None:
raise ValueError("call_id is required for function result content")
return DurableAgentStateFunctionResultContent(call_id=content.call_id, result=content.result)
def to_ai_content(self) -> Content:
return Content.from_function_result(call_id=self.call_id, result=self.result)
@@ -1016,7 +1041,9 @@ class DurableAgentStateHostedFileContent(DurableAgentStateContent):
@staticmethod
def from_hosted_file_content(content: Content) -> DurableAgentStateHostedFileContent:
return DurableAgentStateHostedFileContent(file_id=content.file_id) # type: ignore[arg-type]
if content.file_id is None:
raise ValueError("file_id is required for hosted file content")
return DurableAgentStateHostedFileContent(file_id=content.file_id)
def to_ai_content(self) -> Content:
return Content.from_hosted_file(file_id=self.file_id)
@@ -1050,7 +1077,9 @@ class DurableAgentStateHostedVectorStoreContent(DurableAgentStateContent):
def from_hosted_vector_store_content(
content: Content,
) -> DurableAgentStateHostedVectorStoreContent:
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id) # type: ignore[arg-type]
if content.vector_store_id is None:
raise ValueError("vector_store_id is required for hosted vector store content")
return DurableAgentStateHostedVectorStoreContent(vector_store_id=content.vector_store_id)
def to_ai_content(self) -> Content:
return Content.from_hosted_vector_store(vector_store_id=self.vector_store_id)
@@ -1137,7 +1166,11 @@ class DurableAgentStateUriContent(DurableAgentStateContent):
@staticmethod
def from_uri_content(content: Content) -> DurableAgentStateUriContent:
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type) # type: ignore[arg-type]
if content.uri is None:
raise ValueError("uri is required for uri content")
if content.media_type is None:
raise ValueError("media_type is required for uri content")
return DurableAgentStateUriContent(uri=content.uri, media_type=content.media_type)
def to_ai_content(self) -> Content:
return Content.from_uri(uri=self.uri, media_type=self.media_type)
@@ -1157,6 +1190,14 @@ class DurableAgentStateUsage:
extensionData: Optional additional metadata
"""
# UsageDetails field name constants (snake_case keys from agent_framework.UsageDetails)
_INPUT_TOKEN_COUNT = "input_token_count" # noqa: S105 # nosec B105
_OUTPUT_TOKEN_COUNT = "output_token_count" # noqa: S105 # nosec B105
_TOTAL_TOKEN_COUNT = "total_token_count" # noqa: S105 # nosec B105
# Standard fields in UsageDetails that are mapped to dedicated attributes
_STANDARD_USAGE_FIELDS: ClassVar[set[str]] = {_INPUT_TOKEN_COUNT, _OUTPUT_TOKEN_COUNT, _TOTAL_TOKEN_COUNT}
input_token_count: int | None = None
output_token_count: int | None = None
total_token_count: int | None = None
@@ -1194,22 +1235,31 @@ class DurableAgentStateUsage:
)
@staticmethod
def from_usage(usage: UsageDetails | dict[str, int] | None) -> DurableAgentStateUsage | None:
def from_usage(usage: UsageDetails | MutableMapping[str, int] | None) -> DurableAgentStateUsage | None:
if usage is None:
return None
# Collect all non-standard fields into extension_data
extension_data: dict[str, Any] = {
k: v for k, v in usage.items() if k not in DurableAgentStateUsage._STANDARD_USAGE_FIELDS
}
return DurableAgentStateUsage(
input_token_count=usage.get("input_token_count"),
output_token_count=usage.get("output_token_count"),
total_token_count=usage.get("total_token_count"),
input_token_count=usage.get(DurableAgentStateUsage._INPUT_TOKEN_COUNT),
output_token_count=usage.get(DurableAgentStateUsage._OUTPUT_TOKEN_COUNT),
total_token_count=usage.get(DurableAgentStateUsage._TOTAL_TOKEN_COUNT),
extensionData=extension_data if extension_data else None,
)
def to_usage_details(self) -> UsageDetails:
# Convert back to AI SDK UsageDetails
return {
"input_token_count": self.input_token_count,
"output_token_count": self.output_token_count,
"total_token_count": self.total_token_count,
}
return UsageDetails(
input_token_count=self.input_token_count,
output_token_count=self.output_token_count,
total_token_count=self.total_token_count,
**self.extensionData if self.extensionData else {},
)
class DurableAgentStateUsageContent(DurableAgentStateContent):
@@ -0,0 +1,347 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Task entity implementations for Microsoft Agent Framework."""
from __future__ import annotations
import inspect
from collections.abc import AsyncIterable
from typing import Any, cast
from agent_framework import (
AgentProtocol,
AgentResponse,
AgentResponseUpdate,
ChatMessage,
Content,
Role,
get_logger,
)
from durabletask.entities import DurableEntity
from ._callbacks import AgentCallbackContext, AgentResponseCallbackProtocol
from ._durable_agent_state import (
DurableAgentState,
DurableAgentStateEntry,
DurableAgentStateRequest,
DurableAgentStateResponse,
)
from ._models import RunRequest
logger = get_logger("agent_framework.durabletask.entities")
class AgentEntityStateProviderMixin:
"""Mixin implementing durable agent state caching + (de)serialization + persistence.
Concrete classes must implement:
- _get_state_dict(): fetch raw persisted state dict (default should be {})
- _set_state_dict(): persist raw state dict
- _get_thread_id_from_entity(): fetch the thread ID from the underlying context
"""
_state_cache: DurableAgentState | None = None
def _get_state_dict(self) -> dict[str, Any]:
raise NotImplementedError
def _set_state_dict(self, state: dict[str, Any]) -> None:
raise NotImplementedError
def _get_thread_id_from_entity(self) -> str:
raise NotImplementedError
@property
def thread_id(self) -> str:
return self._get_thread_id_from_entity()
@property
def state(self) -> DurableAgentState:
if self._state_cache is None:
raw_state = self._get_state_dict()
self._state_cache = DurableAgentState.from_dict(raw_state) if raw_state else DurableAgentState()
return self._state_cache
@state.setter
def state(self, value: DurableAgentState) -> None:
self._state_cache = value
self.persist_state()
def persist_state(self) -> None:
"""Persist the current state to the underlying storage provider."""
if self._state_cache is None:
self._state_cache = DurableAgentState()
self._set_state_dict(self._state_cache.to_dict())
def reset(self) -> None:
"""Clear conversation history by resetting state to a fresh DurableAgentState."""
self._state_cache = DurableAgentState()
self.persist_state()
logger.debug("[AgentEntityStateProviderMixin.reset] State reset complete")
class AgentEntity:
"""Platform-agnostic agent execution logic.
This class encapsulates the core logic for executing an agent within a durable entity context.
"""
agent: AgentProtocol
callback: AgentResponseCallbackProtocol | None
def __init__(
self,
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
*,
state_provider: AgentEntityStateProviderMixin,
) -> None:
self.agent = agent
self.callback = callback
self._state_provider = state_provider
logger.debug("[AgentEntity] Initialized with agent type: %s", type(agent).__name__)
@property
def state(self) -> DurableAgentState:
return self._state_provider.state
@state.setter
def state(self, value: DurableAgentState) -> None:
self._state_provider.state = value
def persist_state(self) -> None:
self._state_provider.persist_state()
def reset(self) -> None:
self._state_provider.reset()
def _is_error_response(self, entry: DurableAgentStateEntry) -> bool:
"""Check if a conversation history entry is an error response."""
if isinstance(entry, DurableAgentStateResponse):
return entry.is_error
return False
async def run(
self,
request: RunRequest | dict[str, Any] | str,
) -> AgentResponse:
"""Execute the agent with a message."""
if isinstance(request, str):
run_request = RunRequest.from_json(request)
elif isinstance(request, dict):
run_request = RunRequest.from_dict(request)
else:
run_request = request
message = run_request.message
thread_id = self._state_provider.thread_id
correlation_id = run_request.correlation_id
if not thread_id:
raise ValueError("Entity State Provider must provide a thread_id")
options: dict[str, Any] = dict(run_request.options)
options.setdefault("response_format", run_request.response_format)
if not run_request.enable_tool_calls:
options.setdefault("tools", None)
logger.debug("[AgentEntity.run] Received ThreadId %s Message: %s", thread_id, run_request)
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
try:
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, "options": options}
agent_run_response: AgentResponse = await self._invoke_agent(
run_kwargs=run_kwargs,
correlation_id=correlation_id,
thread_id=thread_id,
request_message=message,
)
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
self.persist_state()
return agent_run_response
except Exception as exc:
logger.exception("[AgentEntity.run] Agent execution failed.")
error_message = ChatMessage(
role=Role.ASSISTANT, contents=[Content.from_error(message=str(exc), error_code=type(exc).__name__)]
)
error_response = AgentResponse(messages=[error_message])
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)
self.persist_state()
return error_response
async def _invoke_agent(
self,
run_kwargs: dict[str, Any],
correlation_id: str,
thread_id: str,
request_message: str,
) -> AgentResponse:
"""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[AgentResponseUpdate], 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[AgentResponseUpdate],
callback_context: AgentCallbackContext | None = None,
) -> AgentResponse:
"""Consume streaming responses and build the final AgentResponse."""
updates: list[AgentResponseUpdate] = []
async for update in stream:
updates.append(update)
await self._notify_stream_update(update, callback_context)
if updates:
response = AgentResponse.from_agent_run_response_updates(updates)
else:
logger.debug("[AgentEntity] No streaming updates received; creating empty response")
response = AgentResponse(messages=[])
await self._notify_final_response(response, callback_context)
return response
async def _invoke_non_stream(self, run_kwargs: dict[str, Any]) -> AgentResponse:
"""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, AgentResponse):
raise TypeError(f"Agent run() must return an AgentResponse instance; received {type(result).__name__}")
return result
async def _notify_stream_update(
self,
update: AgentResponseUpdate,
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: AgentResponse,
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,
)
class DurableTaskEntityStateProvider(DurableEntity, AgentEntityStateProviderMixin):
"""DurableTask Durable Entity state provider for AgentEntity.
This class utilizes the Durable Entity context from `durabletask` package
to get and set the state of the agent entity.
"""
def __init__(self) -> None:
super().__init__()
def _get_state_dict(self) -> dict[str, Any]:
raw = self.get_state(dict, default={})
return cast(dict[str, Any], raw)
def _set_state_dict(self, state: dict[str, Any]) -> None:
self.set_state(state)
def _get_thread_id_from_entity(self) -> str:
return self.entity_context.entity_id.key
@@ -0,0 +1,515 @@
# Copyright (c) Microsoft. All rights reserved.
"""Provider strategies for Durable Agent execution.
These classes are internal execution strategies used by the DurableAIAgent shim.
They are intentionally separate from the public client/orchestration APIs to keep
only `get_agent` exposed to consumers. Executors implement the execution contract
and are injected into the shim.
"""
from __future__ import annotations
import time
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any, Generic, TypeVar
from agent_framework import AgentResponse, AgentThread, ChatMessage, Content, Role, get_logger
from durabletask.client import TaskHubGrpcClient
from durabletask.entities import EntityInstanceId
from durabletask.task import CompletableTask, CompositeTask, OrchestrationContext, Task
from pydantic import BaseModel
from ._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from ._durable_agent_state import DurableAgentState
from ._models import AgentSessionId, DurableAgentThread, RunRequest
from ._response_utils import ensure_response_format, load_agent_response
logger = get_logger("agent_framework.durabletask.executors")
# TypeVar for the task type returned by executors
TaskT = TypeVar("TaskT")
class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentResponse]):
"""A custom Task that wraps entity calls and provides typed AgentResponse results.
This task wraps the underlying entity call task and intercepts its completion
to convert the raw result into a typed AgentResponse object.
When yielded in an orchestration, this task returns an AgentResponse:
response: AgentResponse = yield durable_agent_task
"""
def __init__(
self,
entity_task: CompletableTask[Any],
response_format: type[BaseModel] | None,
correlation_id: str,
):
"""Initialize the DurableAgentTask.
Args:
entity_task: The underlying entity call task
response_format: Optional Pydantic model for response parsing
correlation_id: Correlation ID for logging
"""
self._response_format = response_format
self._correlation_id = correlation_id
super().__init__([entity_task]) # type: ignore
def on_child_completed(self, task: Task[Any]) -> None:
"""Handle completion of the underlying entity task.
Parameters
----------
task : Task
The entity call task that just completed
"""
if self.is_complete:
return
if task.is_failed:
# Propagate the failure - pass the original exception directly
self.fail("call_entity Task failed", task.get_exception())
return
# Task succeeded - transform the raw result
raw_result = task.get_result()
logger.debug(
"[DurableAgentTask] Converting raw result for correlation_id %s",
self._correlation_id,
)
try:
response = load_agent_response(raw_result)
if self._response_format is not None:
ensure_response_format(
self._response_format,
self._correlation_id,
response,
)
# Set the typed AgentResponse as this task's result
self.complete(response)
except Exception as ex:
err_msg = "[DurableAgentTask] Failed to convert result for correlation_id: " + self._correlation_id
logger.exception(err_msg)
self.fail(err_msg, ex)
class DurableAgentExecutor(ABC, Generic[TaskT]):
"""Abstract base class for durable agent execution strategies.
Type Parameters:
TaskT: The task type returned by this executor
"""
@abstractmethod
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> TaskT:
"""Execute the durable agent.
Returns:
TaskT: The task type specific to this executor implementation
"""
raise NotImplementedError
def get_new_thread(self, agent_name: str, **kwargs: Any) -> DurableAgentThread:
"""Create a new DurableAgentThread with random session ID."""
session_id = self._create_session_id(agent_name)
return DurableAgentThread.from_session_id(session_id, **kwargs)
def _create_session_id(
self,
agent_name: str,
thread: AgentThread | None = None,
) -> AgentSessionId:
"""Create the AgentSessionId for the execution."""
if isinstance(thread, DurableAgentThread) and thread.session_id is not None:
return thread.session_id
# Create new session ID - either no thread provided or it's a regular AgentThread
key = self.generate_unique_id()
return AgentSessionId(name=agent_name, key=key)
def generate_unique_id(self) -> str:
"""Generate a new Unique ID."""
return uuid.uuid4().hex
def get_run_request(
self,
message: str,
*,
options: dict[str, Any] | None = None,
) -> RunRequest:
"""Create a RunRequest from message and options."""
correlation_id = self.generate_unique_id()
# Create a copy to avoid modifying the caller's dict
opts = dict(options) if options else {}
# Extract and REMOVE known keys from options copy
response_format = opts.pop("response_format", None)
enable_tool_calls = opts.pop("enable_tool_calls", True)
wait_for_response = opts.pop("wait_for_response", True)
return RunRequest(
message=message,
response_format=response_format,
enable_tool_calls=enable_tool_calls,
wait_for_response=wait_for_response,
correlation_id=correlation_id,
options=opts,
)
def _create_acceptance_response(self, correlation_id: str) -> AgentResponse:
"""Create an acceptance response for fire-and-forget mode.
Args:
correlation_id: Correlation ID for tracking the request
Returns:
AgentResponse: Acceptance response with correlation ID
"""
acceptance_message = ChatMessage(
role=Role.SYSTEM,
contents=[
Content.from_text(
f"Request accepted for processing (correlation_id: {correlation_id}). "
f"Agent is executing in the background. "
f"Retrieve response via your configured streaming or callback mechanism."
)
],
)
return AgentResponse(
messages=[acceptance_message],
created_at=datetime.now(timezone.utc).isoformat(),
)
class ClientAgentExecutor(DurableAgentExecutor[AgentResponse]):
"""Execution strategy for external clients.
Note: Returns AgentResponse directly since the execution
is blocking until response is available via polling
as per the design of TaskHubGrpcClient.
"""
def __init__(
self,
client: TaskHubGrpcClient,
max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES,
poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS,
):
self._client = client
self.max_poll_retries = max_poll_retries
self.poll_interval_seconds = poll_interval_seconds
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> AgentResponse:
"""Execute the agent via the durabletask client.
Signals the agent entity with a message request, then polls the entity
state to retrieve the response once processing is complete.
Note: This is a blocking/synchronous operation (in line with how
TaskHubGrpcClient works) that polls until a response is available or
timeout occurs.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
thread: Optional conversation thread (creates new if not provided)
Returns:
AgentResponse: The agent's response after execution completes, or an immediate
acknowledgement if wait_for_response is False
"""
# Signal the entity with the request
entity_id = self._signal_agent_entity(agent_name, run_request, thread)
# If fire-and-forget mode, return immediately without polling
if not run_request.wait_for_response:
logger.info(
"[ClientAgentExecutor] Fire-and-forget mode: request signaled (correlation: %s)",
run_request.correlation_id,
)
return self._create_acceptance_response(run_request.correlation_id)
# Poll for the response
agent_response = self._poll_for_agent_response(entity_id, run_request.correlation_id)
# Handle and return the result
return self._handle_agent_response(agent_response, run_request.response_format, run_request.correlation_id)
def _signal_agent_entity(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None,
) -> EntityInstanceId:
"""Signal the agent entity with a run request.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
thread: Optional conversation thread
Returns:
entity_id
"""
# Get or create session ID
session_id = self._create_session_id(agent_name, thread)
# Create the entity ID
entity_id = EntityInstanceId(
entity=session_id.entity_name,
key=session_id.key,
)
logger.debug(
"[ClientAgentExecutor] Signaling entity '%s' (session: %s, correlation: %s)",
agent_name,
session_id,
run_request.correlation_id,
)
self._client.signal_entity(entity_id, "run", run_request.to_dict())
return entity_id
def _poll_for_agent_response(
self,
entity_id: EntityInstanceId,
correlation_id: str,
) -> AgentResponse | None:
"""Poll the entity for a response with retries.
Args:
entity_id: Entity instance identifier
correlation_id: Correlation ID to track the request
Returns:
The agent response if found, None if timeout occurs
"""
agent_response = None
for attempt in range(1, self.max_poll_retries + 1):
time.sleep(self.poll_interval_seconds)
agent_response = self._poll_entity_for_response(entity_id, correlation_id)
if agent_response is not None:
logger.info(
"[ClientAgentExecutor] Found response (attempt %d/%d, correlation: %s)",
attempt,
self.max_poll_retries,
correlation_id,
)
break
logger.debug(
"[ClientAgentExecutor] Response not ready (attempt %d/%d)",
attempt,
self.max_poll_retries,
)
return agent_response
def _handle_agent_response(
self,
agent_response: AgentResponse | None,
response_format: type[BaseModel] | None,
correlation_id: str,
) -> AgentResponse:
"""Handle the agent response or create an error response.
Args:
agent_response: The response from polling, or None if timeout
response_format: Optional response format for validation
correlation_id: Correlation ID for logging
Returns:
AgentResponse with either the agent's response or an error message
"""
if agent_response is not None:
try:
# Validate response format if specified
if response_format is not None:
ensure_response_format(
response_format,
correlation_id,
agent_response,
)
return agent_response
except Exception as e:
logger.exception(
"[ClientAgentExecutor] Error converting response for correlation: %s",
correlation_id,
)
error_message = ChatMessage(
role=Role.SYSTEM,
contents=[
Content.from_error(
message=f"Error processing agent response: {e}",
error_code="response_processing_error",
)
],
)
else:
logger.warning(
"[ClientAgentExecutor] Timeout after %d attempts (correlation: %s)",
self.max_poll_retries,
correlation_id,
)
error_message = ChatMessage(
role=Role.SYSTEM,
contents=[
Content.from_error(
message=f"Timeout waiting for agent response after {self.max_poll_retries} attempts",
error_code="response_timeout",
)
],
)
return AgentResponse(
messages=[error_message],
created_at=datetime.now(timezone.utc).isoformat(),
)
def _poll_entity_for_response(
self,
entity_id: EntityInstanceId,
correlation_id: str,
) -> AgentResponse | None:
"""Poll the entity state for a response matching the correlation ID.
Args:
entity_id: Entity instance identifier
correlation_id: Correlation ID to search for
Returns:
Response AgentResponse, None otherwise
"""
try:
entity_metadata = self._client.get_entity(entity_id, include_state=True)
if entity_metadata is None:
return None
state_json = entity_metadata.get_state()
if not state_json:
return None
state = DurableAgentState.from_json(state_json)
# Use the helper method to get response by correlation ID
return state.try_get_agent_response(correlation_id)
except Exception as e:
logger.warning(
"[ClientAgentExecutor] Error reading entity state: %s",
e,
)
return None
class OrchestrationAgentExecutor(DurableAgentExecutor[DurableAgentTask]):
"""Execution strategy for orchestrations (sync/yield)."""
def __init__(self, context: OrchestrationContext):
self._context = context
logger.debug("[OrchestrationAgentExecutor] Initialized")
def generate_unique_id(self) -> str:
"""Create a new UUID that is safe for replay within an orchestration or operation."""
return self._context.new_uuid()
def get_run_request(
self,
message: str,
*,
options: dict[str, Any] | None = None,
) -> RunRequest:
"""Get the current run request from the orchestration context.
Returns:
RunRequest: The current run request
"""
request = super().get_run_request(
message,
options=options,
)
request.orchestration_id = self._context.instance_id
return request
def run_durable_agent(
self,
agent_name: str,
run_request: RunRequest,
thread: AgentThread | None = None,
) -> DurableAgentTask:
"""Execute the agent via orchestration context.
Calls the agent entity and returns a DurableAgentTask that can be yielded
in orchestrations to wait for the entity's response.
Args:
agent_name: Name of the agent to execute
run_request: The run request containing message and optional response format
thread: Optional conversation thread (creates new if not provided)
Returns:
DurableAgentTask: A task wrapping the entity call that yields AgentResponse
"""
# Resolve session
session_id = self._create_session_id(agent_name, thread)
# Create the entity ID
entity_id = EntityInstanceId(
entity=session_id.entity_name,
key=session_id.key,
)
logger.debug(
"[OrchestrationAgentExecutor] correlation_id: %s entity_id: %s session_id: %s",
run_request.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.info(
"[OrchestrationAgentExecutor] Fire-and-forget mode: signaling entity (correlation: %s)",
run_request.correlation_id,
)
self._context.signal_entity(entity_id, "run", run_request.to_dict())
# Create a pre-completed task with acceptance response
acceptance_response = self._create_acceptance_response(run_request.correlation_id)
entity_task: CompletableTask[AgentResponse] = CompletableTask()
entity_task.complete(acceptance_response)
else:
# Blocking mode: call entity and wait for response
entity_task = self._context.call_entity(entity_id, "run", run_request.to_dict()) # type: ignore
# Wrap in DurableAgentTask for response transformation
return DurableAgentTask(
entity_task=entity_task,
response_format=run_request.response_format,
correlation_id=run_request.correlation_id,
)
@@ -8,13 +8,14 @@ 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 dataclasses import dataclass, field
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
@@ -32,195 +33,6 @@ 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, agent_name: str | None = None) -> AgentSessionId:
"""Parses a string representation of an agent session ID.
Args:
session_id_string: A string in the form @name@key, or a plain key string
when agent_name is provided.
agent_name: Optional agent name to use instead of parsing from the string.
If provided, only the key portion is extracted from session_id_string
(for @name@key format) or the entire string is used as the key
(for plain strings).
Returns:
AgentSessionId instance
Raises:
ValueError: If the string format is invalid and agent_name is not provided
"""
# Check if string is in @name@key format
if session_id_string.startswith("@") and "@" in session_id_string[1:]:
parts = session_id_string[1:].split("@", 1)
name = agent_name if agent_name is not None else parts[0]
return AgentSessionId(name=name, key=parts[1])
# Plain string format - only valid when agent_name is provided
if agent_name is not None:
return AgentSessionId(name=agent_name, key=session_id_string)
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
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:
@@ -292,43 +104,48 @@ 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
thread_id: Optional thread ID for tracking
correlation_id: Optional correlation ID for tracking the response to this specific 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
created_at: Optional timestamp when the request was created
orchestration_id: Optional ID of the orchestration that initiated this request
options: Optional options dictionary forwarded to the agent
"""
message: str
request_response_format: str
correlation_id: str
role: Role = Role.USER
response_format: type[BaseModel] | None = None
enable_tool_calls: bool = True
thread_id: str | None = None
correlation_id: str | None = None
created_at: str | None = None
wait_for_response: bool = True
created_at: datetime | None = None
orchestration_id: str | None = None
options: dict[str, Any] = field(default_factory=lambda: {})
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,
thread_id: str | None = None,
correlation_id: str | None = None,
created_at: str | None = None,
wait_for_response: bool = True,
created_at: datetime | None = None,
orchestration_id: str | None = None,
options: dict[str, Any] | 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.thread_id = thread_id
self.correlation_id = correlation_id
self.created_at = created_at
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.orchestration_id = orchestration_id
self.options = options if options is not None else {}
@staticmethod
def coerce_role(value: Role | str | None) -> Role:
@@ -347,33 +164,177 @@ 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,
"options": self.options,
}
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
result["created_at"] = self.created_at.isoformat()
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")
options = data.get("options")
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),
thread_id=data.get("thread_id"),
correlation_id=data.get("correlationId"),
created_at=data.get("created_at"),
created_at=created_at,
orchestration_id=data.get("orchestrationId"),
options=cast(dict[str, Any], options) if isinstance(options, dict) else {},
)
@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, agent_name: str | None = None) -> AgentSessionId:
"""Parses a string representation of an agent session ID.
Args:
session_id_string: A string in the form @name@key, or a plain key string
when agent_name is provided.
agent_name: Optional agent name to use instead of parsing from the string.
If provided, only the key portion is extracted from session_id_string
(for @name@key format) or the entire string is used as the key
(for plain strings).
Returns:
AgentSessionId instance
Raises:
ValueError: If the string format is invalid and agent_name is not provided
"""
# Check if string is in @name@key format
if session_id_string.startswith("@") and "@" in session_id_string[1:]:
parts = session_id_string[1:].split("@", 1)
name = agent_name if agent_name is not None else parts[0]
return AgentSessionId(name=name, key=parts[1])
# Plain string format - only valid when agent_name is provided
if agent_name is not None:
return AgentSessionId(name=agent_name, key=session_id_string)
raise ValueError(f"Invalid agent session ID format: {session_id_string}")
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
@@ -0,0 +1,75 @@
# Copyright (c) Microsoft. All rights reserved.
"""Orchestration context wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentOrchestrationContext class for use inside
orchestration functions to interact with durable agents.
"""
from __future__ import annotations
from agent_framework import get_logger
from durabletask.task import OrchestrationContext
from ._executors import DurableAgentTask, OrchestrationAgentExecutor
from ._shim import DurableAgentProvider, DurableAIAgent
logger = get_logger("agent_framework.durabletask.orchestration_context")
class DurableAIAgentOrchestrationContext(DurableAgentProvider[DurableAgentTask]):
"""Orchestration context wrapper for interacting with durable agents internally.
This class wraps a durabletask OrchestrationContext and provides a convenient
interface for retrieving and executing durable agents from within orchestration
functions.
Example:
```python
from durabletask import Orchestration
from agent_framework.azure import DurableAIAgentOrchestrationContext
def my_orchestration(context: OrchestrationContext):
# Wrap the context
agent_context = DurableAIAgentOrchestrationContext(context)
# Get an agent reference
agent = agent_context.get_agent("assistant")
# Run the agent (returns a Task to be yielded)
result = yield agent.run("Hello, how are you?")
return result.text
```
"""
def __init__(self, context: OrchestrationContext):
"""Initialize the orchestration context wrapper.
Args:
context: The durabletask orchestration context to wrap
"""
self._context = context
self._executor = OrchestrationAgentExecutor(self._context)
logger.debug("[DurableAIAgentOrchestrationContext] Initialized")
def get_agent(self, agent_name: str) -> DurableAIAgent[DurableAgentTask]:
"""Retrieve a DurableAIAgent shim for the specified agent.
This method returns a proxy object that can be used to execute the agent
within an orchestration. The agent's run() method will return a Task that
must be yielded.
Args:
agent_name: Name of the agent to retrieve (without the dafx- prefix)
Returns:
DurableAIAgent instance that can be used to run the agent
Note:
Validation is deferred to execution time. The entity must be registered
on a worker with the name f"dafx-{agent_name}".
"""
logger.debug("[DurableAIAgentOrchestrationContext] Creating agent proxy for: %s", agent_name)
return DurableAIAgent(self._executor, agent_name)
@@ -0,0 +1,72 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared utilities for handling AgentResponse parsing and validation."""
from typing import Any
from agent_framework import AgentResponse, get_logger
from pydantic import BaseModel
logger = get_logger("agent_framework.durabletask.response_utils")
def load_agent_response(agent_response: AgentResponse | dict[str, Any] | None) -> AgentResponse:
"""Convert raw payloads into AgentResponse instance.
Args:
agent_response: The response to convert, can be an AgentResponse, dict, or None
Returns:
AgentResponse: The converted response object
Raises:
ValueError: If agent_response is None
TypeError: If agent_response is an unsupported type
"""
if agent_response is None:
raise ValueError("agent_response cannot be None")
logger.debug("[load_agent_response] Loading agent response of type: %s", type(agent_response))
if isinstance(agent_response, AgentResponse):
return agent_response
if isinstance(agent_response, dict):
logger.debug("[load_agent_response] Converting dict payload using AgentResponse.from_dict")
return AgentResponse.from_dict(agent_response)
raise TypeError(f"Unsupported type for agent_response: {type(agent_response)}")
def ensure_response_format(
response_format: type[BaseModel] | None,
correlation_id: str,
response: AgentResponse,
) -> None:
"""Ensure the AgentResponse value is parsed into the expected response_format.
This function modifies the response in-place by parsing its value attribute
into the specified Pydantic model format.
Args:
response_format: Optional Pydantic model class to parse the response value into
correlation_id: Correlation ID for logging purposes
response: The AgentResponse object to validate and parse
Raises:
ValueError: If response_format is specified but response.value cannot be parsed
"""
if response_format is not None and not isinstance(response.value, response_format):
response.try_parse_value(response_format)
# Validate that parsing succeeded
if not isinstance(response.value, response_format):
raise ValueError(
f"Response value could not be parsed into required format {response_format.__name__} "
f"for correlation_id {correlation_id}"
)
logger.debug(
"[ensure_response_format] Loaded AgentResponse.value for correlation_id %s with type: %s",
correlation_id,
type(response.value).__name__,
)
@@ -0,0 +1,192 @@
# Copyright (c) Microsoft. All rights reserved.
"""Durable Agent Shim for Durable Task Framework.
This module provides the DurableAIAgent shim that implements AgentProtocol
and provides a consistent interface for both Client and Orchestration contexts.
The actual execution is delegated to the context-specific providers.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from typing import Any, Generic, TypeVar
from agent_framework import AgentProtocol, AgentResponseUpdate, AgentThread, ChatMessage
from ._executors import DurableAgentExecutor
from ._models import DurableAgentThread
# TypeVar for the task type returned by executors
# Covariant because TaskT only appears in return positions (output)
TaskT = TypeVar("TaskT", covariant=True)
class DurableAgentProvider(ABC, Generic[TaskT]):
"""Abstract provider for constructing durable agent proxies.
Implemented by context-specific wrappers (client/orchestration) to return a
`DurableAIAgent` shim backed by their respective `DurableAgentExecutor`
implementation, ensuring a consistent `get_agent` entry point regardless of
execution context.
"""
@abstractmethod
def get_agent(self, agent_name: str) -> DurableAIAgent[TaskT]:
"""Retrieve a DurableAIAgent shim for the specified agent.
Args:
agent_name: Name of the agent to retrieve
Returns:
DurableAIAgent instance that can be used to run the agent
Raises:
NotImplementedError: Must be implemented by subclasses
"""
raise NotImplementedError("Subclasses must implement get_agent()")
class DurableAIAgent(AgentProtocol, Generic[TaskT]):
"""A durable agent proxy that delegates execution to the provider.
This class implements AgentProtocol but with one critical difference:
- AgentProtocol.run() returns a Coroutine (async, must await)
- DurableAIAgent.run() returns TaskT (sync Task object - must yield
or the AgentResponse directly in the case of TaskHubGrpcClient)
This represents fundamentally different execution models but maintains the same
interface contract for all other properties and methods.
The underlying provider determines how execution occurs (entity calls, HTTP requests, etc.)
and what type of Task object is returned.
Type Parameters:
TaskT: The task type returned by this agent (e.g., AgentResponse, DurableAgentTask, AgentTask)
"""
def __init__(self, executor: DurableAgentExecutor[TaskT], name: str, *, agent_id: str | None = None):
"""Initialize the shim with a provider and agent name.
Args:
executor: The execution provider (Client or OrchestrationContext)
name: The name of the agent to execute
agent_id: Optional unique identifier for the agent (defaults to name)
"""
self._executor = executor
self._name = name
self._id = agent_id if agent_id is not None else name
self._display_name = name
self._description = f"Durable agent proxy for {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
def run( # pyright: ignore[reportIncompatibleMethodOverride]
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
options: dict[str, Any] | None = None,
) -> TaskT:
"""Execute the agent via the injected provider.
Args:
messages: The message(s) to send to the agent
thread: Optional agent thread for conversation context
options: Optional options dictionary. Supported keys include
``response_format``, ``enable_tool_calls``, and ``wait_for_response``.
Additional keys are forwarded to the agent execution.
Note:
This method overrides AgentProtocol.run() with a different return type:
- AgentProtocol.run() returns Coroutine[Any, Any, AgentResponse] (async)
- DurableAIAgent.run() returns TaskT (Task object for yielding)
This is intentional to support orchestration contexts that use yield patterns
instead of async/await patterns.
Returns:
TaskT: The task type specific to the executor
Raises:
ValueError: If wait_for_response=False is used in an unsupported context
"""
message_str = self._normalize_messages(messages)
run_request = self._executor.get_run_request(
message=message_str,
options=options,
)
return self._executor.run_durable_agent(
agent_name=self._name,
run_request=run_request,
thread=thread,
)
def run_stream(
self,
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
**kwargs: Any,
) -> AsyncIterator[AgentResponseUpdate]:
"""Run the agent with streaming (not supported for durable agents).
Args:
messages: The message(s) to send to the agent
thread: Optional agent thread for conversation context
**kwargs: Additional arguments
Raises:
NotImplementedError: Streaming is not supported for durable agents
"""
raise NotImplementedError("Streaming is not supported for durable agents")
def get_new_thread(self, **kwargs: Any) -> DurableAgentThread:
"""Create a new agent thread via the provider."""
return self._executor.get_new_thread(self._name, **kwargs)
def _normalize_messages(self, messages: str | ChatMessage | list[str] | list[ChatMessage] | None) -> str:
"""Convert supported message inputs to a single string.
Args:
messages: The messages to normalize
Returns:
A single string representation of the messages
"""
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(messages) # type: ignore[arg-type]
# List of ChatMessage
return "\n".join([msg.text or "" for msg in messages]) # type: ignore[union-attr]
return ""
@@ -0,0 +1,218 @@
# Copyright (c) Microsoft. All rights reserved.
"""Worker wrapper for Durable Task Agent Framework.
This module provides the DurableAIAgentWorker class that wraps a durabletask worker
and enables registration of agents as durable entities.
"""
from __future__ import annotations
import asyncio
from typing import Any
from agent_framework import AgentProtocol, get_logger
from durabletask.worker import TaskHubGrpcWorker
from ._callbacks import AgentResponseCallbackProtocol
from ._entities import AgentEntity, DurableTaskEntityStateProvider
logger = get_logger("agent_framework.durabletask.worker")
class DurableAIAgentWorker:
"""Wrapper for durabletask worker that enables agent registration.
This class wraps an existing TaskHubGrpcWorker instance and provides
a convenient interface for registering agents as durable entities.
Example:
```python
from durabletask import TaskHubGrpcWorker
from agent_framework import ChatAgent
from agent_framework.azure import DurableAIAgentWorker
# Create the underlying worker
worker = TaskHubGrpcWorker(host_address="localhost:4001")
# Wrap it with the agent worker
agent_worker = DurableAIAgentWorker(worker)
# Register agents
my_agent = ChatAgent(chat_client=client, name="assistant")
agent_worker.add_agent(my_agent)
# Start the worker
worker.start()
```
"""
def __init__(
self,
worker: TaskHubGrpcWorker,
callback: AgentResponseCallbackProtocol | None = None,
):
"""Initialize the worker wrapper.
Args:
worker: The durabletask worker instance to wrap
callback: Optional callback for agent response notifications
"""
self._worker = worker
self._callback = callback
self._registered_agents: dict[str, AgentProtocol] = {}
logger.debug("[DurableAIAgentWorker] Initialized with worker type: %s", type(worker).__name__)
def add_agent(
self,
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
) -> None:
"""Register an agent with the worker.
This method creates a durable entity class for the agent and registers
it with the underlying durabletask worker. The entity will be accessible
by the name "dafx-{agent_name}".
Args:
agent: The agent to register (must have a name)
callback: Optional callback for this specific agent (overrides worker-level callback)
Raises:
ValueError: If the agent doesn't have a name or is already registered
"""
agent_name = agent.name
if not agent_name:
raise ValueError("Agent must have a name to be registered")
if agent_name in self._registered_agents:
raise ValueError(f"Agent '{agent_name}' is already registered")
logger.info("[DurableAIAgentWorker] Registering agent: %s as entity: dafx-%s", agent_name, agent_name)
# Store the agent reference
self._registered_agents[agent_name] = agent
# Use agent-specific callback if provided, otherwise use worker-level callback
effective_callback = callback or self._callback
# Create a configured entity class using the factory
entity_class = self.__create_agent_entity(agent, effective_callback)
# Register the entity class with the worker
# The worker.add_entity method takes a class
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
logger.debug(
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
entity_registered,
agent_name,
)
def start(self) -> None:
"""Start the worker to begin processing tasks.
Note:
This method delegates to the underlying worker's start method.
The worker will block until stopped.
"""
logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents))
self._worker.start()
def stop(self) -> None:
"""Stop the worker gracefully.
Note:
This method delegates to the underlying worker's stop method.
"""
logger.info("[DurableAIAgentWorker] Stopping worker")
self._worker.stop()
@property
def registered_agent_names(self) -> list[str]:
"""Get the names of all registered agents.
Returns:
List of agent names (without the dafx- prefix)
"""
return list(self._registered_agents.keys())
def __create_agent_entity(
self,
agent: AgentProtocol,
callback: AgentResponseCallbackProtocol | None = None,
) -> type[DurableTaskEntityStateProvider]:
"""Factory function to create a DurableEntity class configured with an agent.
This factory creates a new class that combines the entity state provider
with the agent execution logic. Each agent gets its own entity class.
Args:
agent: The agent instance to wrap
callback: Optional callback for agent responses
Returns:
A new DurableEntity subclass configured for this agent
"""
agent_name = agent.name or type(agent).__name__
entity_name = f"dafx-{agent_name}"
class ConfiguredAgentEntity(DurableTaskEntityStateProvider):
"""Durable entity configured with a specific agent instance."""
def __init__(self) -> None:
super().__init__()
# Create the AgentEntity with this state provider
self._agent_entity = AgentEntity(
agent=agent,
callback=callback,
state_provider=self,
)
logger.debug(
"[ConfiguredAgentEntity] Initialized entity for agent: %s (entity name: %s)",
agent_name,
entity_name,
)
def run(self, request: Any) -> Any:
"""Handle run requests from clients or orchestrations.
Args:
request: RunRequest as dict or string
Returns:
AgentResponse as dict
"""
logger.debug("[ConfiguredAgentEntity.run] Executing agent: %s", agent_name)
# Get or create event loop for async execution
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Run the async agent execution synchronously
if loop.is_running():
# If loop is already running (shouldn't happen in entity context),
# create a temporary loop
temp_loop = asyncio.new_event_loop()
try:
response = temp_loop.run_until_complete(self._agent_entity.run(request))
finally:
temp_loop.close()
else:
response = loop.run_until_complete(self._agent_entity.run(request))
return response.to_dict()
def reset(self) -> None:
"""Reset the agent's conversation history."""
logger.debug("[ConfiguredAgentEntity.reset] Resetting agent: %s", agent_name)
self._agent_entity.reset()
# Set the entity name to match the prefixed agent name
# This is used by durabletask to register the entity
ConfiguredAgentEntity.__name__ = entity_name
ConfiguredAgentEntity.__qualname__ = entity_name
return ConfiguredAgentEntity
+101
View File
@@ -0,0 +1,101 @@
[project]
name = "agent-framework-durabletask"
description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "0.0.1b260113"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core",
"durabletask>=1.3.0",
"durabletask-azuremanaged>=1.3.0"
]
[dependency-groups]
dev = [
"types-python-dateutil>=2.9.0",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
"ignore:Support for class-based `config` is deprecated:DeprecationWarning:pydantic.*"
]
timeout = 120
markers = [
"integration: marks tests as integration tests",
"integration_test: marks tests as integration tests (alternative marker)",
"sample: marks tests as sample tests",
"requires_azure_openai: marks tests that require Azure OpenAI",
"requires_dts: marks tests that require Durable Task Scheduler",
"requires_redis: marks tests that require Redis"
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_durabletask"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks]
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_durabletask"
test = "pytest --cov=agent_framework_durabletask --cov-report=term-missing:skip-covered tests"
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,17 @@
# Azure OpenAI Configuration
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name
# Optional: Use Azure CLI authentication if not provided
# AZURE_OPENAI_API_KEY=your-api-key
# Durable Task Scheduler Configuration
ENDPOINT=http://localhost:8080
TASKHUB=default
# Redis Configuration (for streaming tests)
REDIS_CONNECTION_STRING=redis://localhost:6379
REDIS_STREAM_TTL_MINUTES=10
# Integration Test Control
# Set to 'true' to enable integration tests
RUN_INTEGRATION_TESTS=true
@@ -0,0 +1,111 @@
# Sample Integration Tests
Integration tests that validate the Durable Agent Framework samples by running them against a Durable Task Scheduler (DTS) instance.
## Setup
### 1. Create `.env` file
Copy `.env.example` to `.env` and fill in your Azure credentials:
```bash
cp .env.example .env
```
Required variables:
- `AZURE_OPENAI_ENDPOINT`
- `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`
- `AZURE_OPENAI_API_KEY` (optional if using Azure CLI authentication)
- `RUN_INTEGRATION_TESTS` (set to `true`)
- `ENDPOINT` (default: http://localhost:8080)
- `TASKHUB` (default: default)
Optional variables (for streaming tests):
- `REDIS_CONNECTION_STRING` (default: redis://localhost:6379)
- `REDIS_STREAM_TTL_MINUTES` (default: 10)
### 2. Start required services
**Durable Task Scheduler:**
```bash
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
- Port 8080: gRPC endpoint (used by tests)
- Port 8082: Web dashboard (optional, for monitoring)
**Redis (for streaming tests):**
```bash
docker run -d --name redis -p 6379:6379 redis:latest
```
- Port 6379: Redis server endpoint
## Running Tests
The tests automatically start and stop worker processes for each sample.
### Run all sample tests
```bash
uv run pytest packages/durabletask/tests/integration_tests -v
```
### Run specific sample
```bash
uv run pytest packages/durabletask/tests/integration_tests/test_01_single_agent.py -v
```
### Run with verbose output
```bash
uv run pytest packages/durabletask/tests/integration_tests -sv
```
## How It Works
Each test file uses pytest markers to automatically configure and start the worker process:
```python
pytestmark = [
pytest.mark.sample("03_single_agent_streaming"),
pytest.mark.integration_test,
pytest.mark.requires_azure_openai,
pytest.mark.requires_dts,
pytest.mark.requires_redis,
]
```
## Troubleshooting
**Tests are skipped:**
Ensure `RUN_INTEGRATION_TESTS=true` is set in your `.env` file.
**DTS connection failed:**
Check that the DTS emulator container is running: `docker ps | grep dts-emulator`
**Redis connection failed:**
Check that Redis is running: `docker ps | grep redis`
**Missing environment variables:**
Ensure your `.env` file contains all required variables from `.env.example`.
**Tests timeout:**
Check that Azure OpenAI credentials are valid and the service is accessible.
If you see "DTS emulator is not available":
- Ensure Docker container is running: `docker ps | grep dts-emulator`
- Check port 8080 is not in use by another process
- Restart the container if needed
### Azure OpenAI Errors
If you see authentication or deployment errors:
- Verify your `AZURE_OPENAI_ENDPOINT` is correct
- Confirm `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME` matches your deployment
- If using API key, check `AZURE_OPENAI_API_KEY` is valid
- If using Azure CLI, ensure you're logged in: `az login`
## CI/CD
For automated testing in CI/CD pipelines:
1. Use Docker Compose to start DTS emulator
2. Set environment variables via CI/CD secrets
3. Run tests with appropriate markers: `pytest -m integration_test`
@@ -0,0 +1,234 @@
# Copyright (c) Microsoft. All rights reserved.
"""Pytest configuration and fixtures for durabletask integration tests."""
import asyncio
import logging
import os
import subprocess
import sys
import time
import uuid
from collections.abc import Generator
from pathlib import Path
from typing import Any, cast
import pytest
import redis.asyncio as aioredis
from dotenv import load_dotenv
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
# Add the integration_tests directory to the path so testutils can be imported
sys.path.insert(0, str(Path(__file__).parent))
# Load environment variables from .env file
load_dotenv(Path(__file__).parent / ".env")
# Configure logging to reduce noise during tests
logging.basicConfig(level=logging.WARNING)
def _get_dts_endpoint() -> str:
"""Get the DTS endpoint from environment or use default."""
return os.getenv("ENDPOINT", "http://localhost:8080")
def _check_dts_available(endpoint: str | None = None) -> bool:
"""Check if DTS emulator is available at the given endpoint."""
try:
resolved_endpoint: str = _get_dts_endpoint() if endpoint is None else endpoint
DurableTaskSchedulerClient(
host_address=resolved_endpoint,
secure_channel=False,
taskhub="test",
token_credential=None,
)
return True
except Exception:
return False
def _check_redis_available() -> bool:
"""Check if Redis is available at the default connection string."""
try:
async def test_connection() -> bool:
redis_url = os.getenv("REDIS_CONNECTION_STRING", "redis://localhost:6379")
try:
client = aioredis.from_url(redis_url, socket_timeout=2) # type: ignore[reportUnknownMemberType]
await client.ping() # type: ignore[reportUnknownMemberType]
await client.aclose() # type: ignore[reportUnknownMemberType]
return True
except Exception:
return False
return asyncio.run(test_connection())
except Exception:
return False
def pytest_configure(config: pytest.Config) -> None:
"""Register custom markers."""
config.addinivalue_line("markers", "integration_test: mark test as integration test")
config.addinivalue_line("markers", "requires_dts: mark test as requiring DTS emulator")
config.addinivalue_line("markers", "requires_azure_openai: mark test as requiring Azure OpenAI")
config.addinivalue_line("markers", "requires_redis: mark test as requiring Redis")
config.addinivalue_line(
"markers",
"sample(path): specify the sample directory name for the test (e.g., @pytest.mark.sample('01_single_agent'))",
)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
"""Skip tests based on markers and environment availability."""
run_integration = os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
skip_integration = pytest.mark.skip(reason="RUN_INTEGRATION_TESTS not set to 'true'")
# Check Azure OpenAI environment variables
azure_openai_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
azure_openai_available = all(os.getenv(var) for var in azure_openai_vars)
skip_azure_openai = pytest.mark.skip(
reason=f"Missing required environment variables: {', '.join(azure_openai_vars)}"
)
# Check DTS availability
dts_available = _check_dts_available()
skip_dts = pytest.mark.skip(reason=f"DTS emulator is not available at {_get_dts_endpoint()}")
# Check Redis availability
redis_available = _check_redis_available()
skip_redis = pytest.mark.skip(reason="Redis is not available at redis://localhost:6379")
for item in items:
if "integration_test" in item.keywords and not run_integration:
item.add_marker(skip_integration)
if "requires_azure_openai" in item.keywords and not azure_openai_available:
item.add_marker(skip_azure_openai)
if "requires_dts" in item.keywords and not dts_available:
item.add_marker(skip_dts)
if "requires_redis" in item.keywords and not redis_available:
item.add_marker(skip_redis)
@pytest.fixture(scope="session")
def dts_endpoint() -> str:
"""Get the DTS endpoint from environment or use default."""
return _get_dts_endpoint()
@pytest.fixture(scope="session")
def dts_available(dts_endpoint: str) -> bool:
"""Check if DTS emulator is available and responding."""
if _check_dts_available(dts_endpoint):
return True
pytest.skip(f"DTS emulator is not available at {dts_endpoint}")
return False
@pytest.fixture(scope="session")
def check_azure_openai_env() -> None:
"""Verify Azure OpenAI environment variables are set."""
required_vars = ["AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"]
missing = [var for var in required_vars if not os.getenv(var)]
if missing:
pytest.skip(f"Missing required environment variables: {', '.join(missing)}")
@pytest.fixture(scope="module")
def unique_taskhub() -> str:
"""Generate a unique task hub name for test isolation."""
# Use a shorter UUID to avoid naming issues
return f"test-{uuid.uuid4().hex[:8]}"
@pytest.fixture(scope="module")
def worker_process(
dts_available: bool,
check_azure_openai_env: None,
dts_endpoint: str,
unique_taskhub: str,
request: pytest.FixtureRequest,
) -> Generator[dict[str, Any], None, None]:
"""
Start a worker process for the current test module by running the sample worker.py.
This fixture:
1. Determines which sample to run from @pytest.mark.sample()
2. Starts the sample's worker.py as a subprocess
3. Waits for the worker to be ready
4. Tears down the worker after tests complete
Usage:
@pytest.mark.sample("01_single_agent")
class TestSingleAgent:
...
"""
# Get sample path from marker
sample_marker = request.node.get_closest_marker("sample") # type: ignore[union-attr]
if not sample_marker:
pytest.fail("Test class must have @pytest.mark.sample() marker")
sample_name: str = cast(str, sample_marker.args[0]) # type: ignore[union-attr]
sample_path: Path = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / sample_name
worker_file: Path = sample_path / "worker.py"
if not worker_file.exists():
pytest.fail(f"Sample worker not found: {worker_file}")
# Set up environment for worker subprocess
env = os.environ.copy()
env["ENDPOINT"] = dts_endpoint
env["TASKHUB"] = unique_taskhub
# Start worker subprocess
try:
# On Windows, use CREATE_NEW_PROCESS_GROUP to allow proper termination
# shell=True only on Windows to handle PATH resolution
if sys.platform == "win32":
process = subprocess.Popen(
[sys.executable, str(worker_file)],
cwd=str(sample_path),
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
shell=True,
env=env,
text=True,
)
# On Unix, don't use shell=True to avoid shell wrapper issues
else:
process = subprocess.Popen(
[sys.executable, str(worker_file)],
cwd=str(sample_path),
env=env,
text=True,
)
except Exception as e:
pytest.fail(f"Failed to start worker subprocess: {e}")
# Wait for worker to initialize
time.sleep(2)
# Check if process is still running
if process.poll() is not None:
stderr_output = process.stderr.read() if process.stderr else ""
pytest.fail(f"Worker process exited prematurely. stderr: {stderr_output}")
# Provide worker info to tests
worker_info = {
"process": process,
"endpoint": dts_endpoint,
"taskhub": unique_taskhub,
}
try:
yield worker_info
finally:
# Cleanup: terminate worker subprocess
try:
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait()
except Exception as e:
logging.warning(f"Error during worker process cleanup: {e}")
@@ -0,0 +1,205 @@
# Copyright (c) Microsoft. All rights reserved.
"""Test utilities for durabletask integration tests."""
import json
import time
from typing import Any
from durabletask.azuremanaged.client import DurableTaskSchedulerClient
from durabletask.client import OrchestrationStatus
from agent_framework_durabletask import DurableAIAgentClient
def create_dts_client(endpoint: str, taskhub: str) -> DurableTaskSchedulerClient:
"""
Create a DurableTaskSchedulerClient with common configuration.
Args:
endpoint: The DTS endpoint address
taskhub: The task hub name
Returns:
A configured DurableTaskSchedulerClient instance
"""
return DurableTaskSchedulerClient(
host_address=endpoint,
secure_channel=False,
taskhub=taskhub,
token_credential=None,
)
def create_agent_client(
endpoint: str,
taskhub: str,
max_poll_retries: int = 90,
) -> tuple[DurableTaskSchedulerClient, DurableAIAgentClient]:
"""
Create a DurableAIAgentClient with the underlying DTS client.
Args:
endpoint: The DTS endpoint address
taskhub: The task hub name
max_poll_retries: Max poll retries for the agent client
Returns:
A tuple of (DurableTaskSchedulerClient, DurableAIAgentClient)
"""
dts_client = create_dts_client(endpoint, taskhub)
agent_client = DurableAIAgentClient(dts_client, max_poll_retries=max_poll_retries)
return dts_client, agent_client
class OrchestrationHelper:
"""Helper class for orchestration-related test operations."""
def __init__(self, dts_client: DurableTaskSchedulerClient):
"""
Initialize the orchestration helper.
Args:
dts_client: The DurableTaskSchedulerClient instance to use
"""
self.client = dts_client
def wait_for_orchestration(
self,
instance_id: str,
timeout: float = 60.0,
) -> Any:
"""
Wait for an orchestration to complete.
Args:
instance_id: The orchestration instance ID
timeout: Maximum time to wait in seconds
Returns:
The final OrchestrationMetadata
Raises:
TimeoutError: If the orchestration doesn't complete within timeout
RuntimeError: If the orchestration fails
"""
# Use the built-in wait_for_orchestration_completion method
metadata = self.client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=int(timeout),
)
if metadata is None:
raise TimeoutError(f"Orchestration {instance_id} did not complete within {timeout} seconds")
# Check if failed or terminated
if metadata.runtime_status == OrchestrationStatus.FAILED:
raise RuntimeError(f"Orchestration {instance_id} failed: {metadata.serialized_custom_status}")
if metadata.runtime_status == OrchestrationStatus.TERMINATED:
raise RuntimeError(f"Orchestration {instance_id} was terminated")
return metadata
def wait_for_orchestration_with_output(
self,
instance_id: str,
timeout: float = 60.0,
) -> tuple[Any, Any]:
"""
Wait for an orchestration to complete and return its output.
Args:
instance_id: The orchestration instance ID
timeout: Maximum time to wait in seconds
Returns:
A tuple of (OrchestrationMetadata, output)
Raises:
TimeoutError: If the orchestration doesn't complete within timeout
RuntimeError: If the orchestration fails
"""
metadata = self.wait_for_orchestration(instance_id, timeout)
# The output should be available in the metadata
return metadata, metadata.serialized_output
def get_orchestration_status(self, instance_id: str) -> Any | None:
"""
Get the current status of an orchestration.
Args:
instance_id: The orchestration instance ID
Returns:
The OrchestrationMetadata or None if not found
"""
try:
# Try to wait with a short timeout to get current status
return self.client.wait_for_orchestration_completion(
instance_id=instance_id,
timeout=1, # Very short timeout, just checking status
)
except Exception:
return None
def raise_event(
self,
instance_id: str,
event_name: str,
event_data: Any = None,
) -> None:
"""
Raise an external event to an orchestration.
Args:
instance_id: The orchestration instance ID
event_name: The name of the event
event_data: The event data payload
"""
self.client.raise_orchestration_event(instance_id, event_name, data=event_data)
def wait_for_notification(self, instance_id: str, timeout_seconds: int = 30) -> bool:
"""Wait for the orchestration to reach a notification point.
Polls the orchestration status until it appears to be waiting for approval.
Args:
instance_id: The orchestration instance ID
timeout_seconds: Maximum time to wait
Returns:
True if notification detected, False if timeout
"""
start_time = time.time()
while time.time() - start_time < timeout_seconds:
try:
metadata = self.client.get_orchestration_state(
instance_id=instance_id,
)
if metadata:
# Check if we're waiting for approval by examining custom status
if metadata.serialized_custom_status:
try:
custom_status = json.loads(metadata.serialized_custom_status)
# Handle both string and dict custom status
status_str = custom_status if isinstance(custom_status, str) else str(custom_status)
if status_str.lower().startswith("requesting human feedback"):
return True
except (json.JSONDecodeError, AttributeError):
# If it's not JSON, treat as plain string
if metadata.serialized_custom_status.lower().startswith("requesting human feedback"):
return True
# Check for terminal states
if metadata.runtime_status.name == "COMPLETED" or metadata.runtime_status.name == "FAILED":
return False
except Exception:
# Silently ignore transient errors during polling (e.g., network issues, service unavailable).
# The loop will retry until timeout, allowing the service to recover.
pass
time.sleep(1)
return False
@@ -0,0 +1,89 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for single agent functionality.
Tests basic agent operations including:
- Agent registration and retrieval
- Single agent interactions
- Conversation continuity across multiple messages
- Multi-threaded agent usage
- Empty thread ID handling
"""
from typing import Any
import pytest
from dt_testutils import create_agent_client
# Module-level markers - applied to all tests in this module
pytestmark = [
pytest.mark.sample("01_single_agent"),
pytest.mark.integration_test,
pytest.mark.requires_azure_openai,
pytest.mark.requires_dts,
]
class TestSingleAgent:
"""Test suite for single agent functionality."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = dts_endpoint
self.taskhub: str = str(worker_process["taskhub"])
# Create agent client
_, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
def test_agent_registration(self) -> None:
"""Test that the Joker agent is registered and accessible."""
agent = self.agent_client.get_agent("Joker")
assert agent is not None
assert agent.name == "Joker"
def test_single_interaction(self):
"""Test a single interaction with the agent."""
agent = self.agent_client.get_agent("Joker")
thread = agent.get_new_thread()
response = agent.run("Tell me a short joke about programming.", thread=thread)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
def test_conversation_continuity(self):
"""Test that conversation context is maintained across turns."""
agent = self.agent_client.get_agent("Joker")
thread = agent.get_new_thread()
# First turn: Ask for a joke about a specific topic
response1 = agent.run("Tell me a joke about cats.", thread=thread)
assert response1 is not None
assert len(response1.text) > 0
# Second turn: Ask a follow-up that requires context
response2 = agent.run("Can you make it funnier?", thread=thread)
assert response2 is not None
assert len(response2.text) > 0
# The agent should understand "it" refers to the previous joke
def test_multiple_threads(self):
"""Test that different threads maintain separate contexts."""
agent = self.agent_client.get_agent("Joker")
# Create two separate threads
thread1 = agent.get_new_thread()
thread2 = agent.get_new_thread()
assert thread1.session_id != thread2.session_id
# Send different messages to each thread
response1 = agent.run("Tell me a joke about dogs.", thread=thread1)
response2 = agent.run("Tell me a joke about birds.", thread=thread2)
assert response1 is not None
assert response2 is not None
assert response1.text != response2.text
@@ -0,0 +1,103 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for multi-agent functionality.
Tests operations with multiple specialized agents:
- Multiple agent registration
- Agent-specific tool usage
- Independent thread management per agent
- Concurrent agent operations
- Agent isolation and tool routing
"""
from typing import Any
import pytest
from dt_testutils import create_agent_client
# Agent names from the 02_multi_agent sample
WEATHER_AGENT_NAME: str = "WeatherAgent"
MATH_AGENT_NAME: str = "MathAgent"
# Module-level markers - applied to all tests in this module
pytestmark = [
pytest.mark.sample("02_multi_agent"),
pytest.mark.integration_test,
pytest.mark.requires_azure_openai,
pytest.mark.requires_dts,
]
class TestMultiAgent:
"""Test suite for multi-agent functionality."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = dts_endpoint
self.taskhub: str = str(worker_process["taskhub"])
# Create agent client
_, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
def test_multiple_agents_registered(self) -> None:
"""Test that both agents are registered and accessible."""
weather_agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
math_agent = self.agent_client.get_agent(MATH_AGENT_NAME)
assert weather_agent is not None
assert weather_agent.name == WEATHER_AGENT_NAME
assert math_agent is not None
assert math_agent.name == MATH_AGENT_NAME
def test_weather_agent_with_tool(self):
"""Test weather agent with weather tool execution."""
agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
thread = agent.get_new_thread()
response = agent.run("What's the weather in Seattle?", thread=thread)
assert response is not None
assert response.text is not None
# Should contain weather information from the tool
assert len(response.text) > 0
# Verify that the get_weather tool was actually invoked
tool_calls = [
content for msg in response.messages for content in msg.contents if content.type == "function_call"
]
assert len(tool_calls) > 0, "Expected at least one tool call"
assert any(call.name == "get_weather" for call in tool_calls), "Expected get_weather tool to be called"
def test_math_agent_with_tool(self):
"""Test math agent with calculation tool execution."""
agent = self.agent_client.get_agent(MATH_AGENT_NAME)
thread = agent.get_new_thread()
response = agent.run("Calculate a 20% tip on a $50 bill.", thread=thread)
assert response is not None
assert response.text is not None
# Should contain calculation results from the tool
assert len(response.text) > 0
# Verify that the calculate_tip tool was actually invoked
tool_calls = [
content for msg in response.messages for content in msg.contents if content.type == "function_call"
]
assert len(tool_calls) > 0, "Expected at least one tool call"
assert any(call.name == "calculate_tip" for call in tool_calls), "Expected calculate_tip tool to be called"
def test_multiple_calls_to_same_agent(self):
"""Test multiple sequential calls to the same agent."""
agent = self.agent_client.get_agent(WEATHER_AGENT_NAME)
thread = agent.get_new_thread()
# Multiple weather queries
response1 = agent.run("What's the weather in Chicago?", thread=thread)
response2 = agent.run("And what about Los Angeles?", thread=thread)
assert response1 is not None
assert response2 is not None
assert len(response1.text) > 0
assert len(response2.text) > 0
@@ -0,0 +1,226 @@
# 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 worker process is automatically started by the test fixture.
Prerequisites:
- Azure OpenAI credentials configured (see packages/durabletask/tests/integration_tests/.env.example)
- DTS emulator running (docker run -d -p 8080:8080 mcr.microsoft.com/durabletask/emulator:latest)
- Redis running (docker run -d --name redis -p 6379:6379 redis:latest)
Usage:
uv run pytest packages/durabletask/tests/integration_tests/test_03_single_agent_streaming.py -v
"""
import asyncio
import os
import sys
import time
from datetime import timedelta
from pathlib import Path
from typing import Any
import pytest
import redis.asyncio as aioredis
from dt_testutils import OrchestrationHelper, create_agent_client
# Add sample directory to path to import RedisStreamResponseHandler
SAMPLE_DIR = Path(__file__).parents[4] / "samples" / "getting_started" / "durabletask" / "03_single_agent_streaming"
sys.path.insert(0, str(SAMPLE_DIR))
from redis_stream_response_handler import RedisStreamResponseHandler # type: ignore[reportMissingImports] # noqa: E402
# Module-level markers - applied to all tests in this file
pytestmark = [
pytest.mark.sample("03_single_agent_streaming"),
pytest.mark.integration_test,
pytest.mark.requires_azure_openai,
pytest.mark.requires_dts,
pytest.mark.requires_redis,
]
class TestSampleReliableStreaming:
"""Tests for 03_single_agent_streaming sample."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = dts_endpoint
self.taskhub: str = str(worker_process["taskhub"])
# Create agent client
dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
self.helper = OrchestrationHelper(dts_client)
# Redis configuration
self.redis_connection_string = os.environ.get("REDIS_CONNECTION_STRING", "redis://localhost:6379")
self.redis_stream_ttl_minutes = int(os.environ.get("REDIS_STREAM_TTL_MINUTES", "10"))
async def _get_stream_handler(self) -> RedisStreamResponseHandler: # type: ignore[reportMissingTypeStubs]
"""Create a new Redis stream handler for each request."""
redis_client = aioredis.from_url( # type: ignore[reportUnknownMemberType]
self.redis_connection_string,
encoding="utf-8",
decode_responses=False,
)
return RedisStreamResponseHandler( # type: ignore[reportUnknownMemberType]
redis_client=redis_client,
stream_ttl=timedelta(minutes=self.redis_stream_ttl_minutes),
)
async def _stream_from_redis(
self,
thread_id: str,
cursor: str | None = None,
timeout: float = 30.0,
) -> tuple[str, bool, str]:
"""
Stream responses from Redis using the sample's RedisStreamResponseHandler.
Args:
thread_id: The conversation/thread ID to stream from
cursor: Optional cursor to resume from
timeout: Maximum time to wait for stream completion
Returns:
Tuple of (accumulated text, completion status, last entry_id)
"""
accumulated_text = ""
is_complete = False
last_entry_id = cursor if cursor else "0-0"
start_time = time.time()
async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType]
try:
async for chunk in stream_handler.read_stream(thread_id, cursor): # type: ignore[reportUnknownMemberType]
if time.time() - start_time > timeout:
break
last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType]
if chunk.error: # type: ignore[reportUnknownMemberType]
# Stream not found or timeout - this is expected if agent hasn't written yet
# Don't raise an error, just return what we have
break
if chunk.is_done: # type: ignore[reportUnknownMemberType]
is_complete = True
break
if chunk.text: # type: ignore[reportUnknownMemberType]
accumulated_text += chunk.text # type: ignore[reportUnknownMemberType]
except Exception as ex:
# For test purposes, we catch exceptions and return what we have
if "timed out" not in str(ex).lower():
raise
return accumulated_text, is_complete, last_entry_id # type: ignore[reportReturnType]
def test_agent_run_and_stream(self) -> None:
"""Test agent execution with Redis streaming."""
# Get the TravelPlanner agent
travel_planner = self.agent_client.get_agent("TravelPlanner")
assert travel_planner is not None
assert travel_planner.name == "TravelPlanner"
# Create a new thread
thread = travel_planner.get_new_thread()
assert thread.session_id is not None
assert thread.session_id.key is not None
thread_id = str(thread.session_id.key)
# Start agent run with wait_for_response=False for non-blocking execution
travel_planner.run(
"Plan a 1-day trip to Seattle in 1 sentence", thread=thread, options={"wait_for_response": False}
)
# Poll Redis stream with retries to handle race conditions
# The agent may take a few seconds to process and start writing to Redis
# We use cursor-based resumption to continue reading from where we left off
max_retries = 20
retry_count = 0
accumulated_text = ""
is_complete = False
cursor: str | None = None
while retry_count < max_retries and not is_complete:
text, is_complete, last_cursor = asyncio.run(
self._stream_from_redis(thread_id, cursor=cursor, timeout=10.0)
)
accumulated_text += text
cursor = last_cursor # Resume from last position on next read
if is_complete:
# Stream completed successfully
break
if len(accumulated_text) > 0:
# Got content but not completion marker yet - keep reading without delay
# The agent may still be streaming or about to write completion marker
continue
# No content yet - wait before retrying
time.sleep(2)
retry_count += 1
# Verify we got content
assert len(accumulated_text) > 0, (
f"Expected text content but got empty string for thread_id: {thread_id} after {retry_count} retries"
)
assert "seattle" in accumulated_text.lower(), f"Expected 'seattle' in response but got: {accumulated_text}"
assert is_complete, "Expected stream to be complete"
def test_stream_with_cursor_resumption(self) -> None:
"""Test streaming with cursor-based resumption."""
# Get the TravelPlanner agent
travel_planner = self.agent_client.get_agent("TravelPlanner")
thread = travel_planner.get_new_thread()
assert thread.session_id is not None
assert thread.session_id.key is not None
thread_id = str(thread.session_id.key)
# Start agent run
travel_planner.run("What's the weather like?", thread=thread, options={"wait_for_response": False})
# Wait for agent to start writing
time.sleep(3)
# Read partial stream to get a cursor
async def get_partial_stream() -> tuple[str, str]:
async with await self._get_stream_handler() as stream_handler: # type: ignore[reportUnknownMemberType]
accumulated_text = ""
last_entry_id = "0-0"
chunk_count = 0
# Read just first 2 chunks
async for chunk in stream_handler.read_stream(thread_id): # type: ignore[reportUnknownMemberType]
last_entry_id = chunk.entry_id # type: ignore[reportUnknownMemberType]
if chunk.text: # type: ignore[reportUnknownMemberType]
accumulated_text += chunk.text # type: ignore[reportUnknownMemberType]
chunk_count += 1
if chunk_count >= 2:
break
return accumulated_text, last_entry_id # type: ignore[reportReturnType]
partial_text, cursor = asyncio.run(get_partial_stream())
# Resume from cursor
remaining_text, _, _ = asyncio.run(self._stream_from_redis(thread_id, cursor=cursor))
# Verify we got some initial content
assert len(partial_text) > 0
# Combined text should be coherent
full_text = partial_text + remaining_text
assert len(full_text) > 0
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,105 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for single agent orchestration with chaining.
Tests orchestration patterns with sequential agent calls:
- Orchestration registration and execution
- Sequential agent calls on same thread
- Conversation continuity in orchestrations
- Thread context preservation
"""
import json
import logging
from typing import Any
import pytest
from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent name from the 04_single_agent_orchestration_chaining sample
WRITER_AGENT_NAME: str = "WriterAgent"
# Configure logging
logging.basicConfig(level=logging.WARNING)
# Module-level markers - applied to all tests in this module
pytestmark = [
pytest.mark.sample("04_single_agent_orchestration_chaining"),
pytest.mark.integration_test,
pytest.mark.requires_azure_openai,
pytest.mark.requires_dts,
]
class TestSingleAgentOrchestrationChaining:
"""Test suite for single agent orchestration with chaining."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = dts_endpoint
self.taskhub: str = str(worker_process["taskhub"])
# Create agent client and DTS client
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
# Create orchestration helper
self.orch_helper = OrchestrationHelper(self.dts_client)
def test_agent_registered(self):
"""Test that the Writer agent is registered."""
agent = self.agent_client.get_agent(WRITER_AGENT_NAME)
assert agent is not None
assert agent.name == WRITER_AGENT_NAME
def test_chaining_context_preserved(self):
"""Test that context is preserved across agent runs in orchestration."""
# Start the orchestration
instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="single_agent_chaining_orchestration",
input="",
)
# Wait for completion with output
metadata, output = self.orch_helper.wait_for_orchestration_with_output(
instance_id=instance_id,
timeout=120.0,
)
assert metadata is not None
assert output is not None
# The final output should be a refined sentence
final_text = json.loads(output)
# Should be a meaningful sentence (not empty or error message)
assert len(final_text) > 10
assert not final_text.startswith("Error")
def test_multiple_orchestration_instances(self):
"""Test that multiple orchestration instances can run independently."""
# Start two orchestrations
instance_id_1 = self.dts_client.schedule_new_orchestration(
orchestrator="single_agent_chaining_orchestration",
input="",
)
instance_id_2 = self.dts_client.schedule_new_orchestration(
orchestrator="single_agent_chaining_orchestration",
input="",
)
assert instance_id_1 != instance_id_2
# Both should complete
metadata_1 = self.orch_helper.wait_for_orchestration(
instance_id=instance_id_1,
timeout=120.0,
)
metadata_2 = self.orch_helper.wait_for_orchestration(
instance_id=instance_id_2,
timeout=120.0,
)
assert metadata_1.runtime_status == OrchestrationStatus.COMPLETED
assert metadata_2.runtime_status == OrchestrationStatus.COMPLETED
@@ -0,0 +1,81 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for multi-agent orchestration with concurrency.
Tests concurrent execution patterns:
- Parallel agent execution
- Concurrent orchestration tasks
- Independent thread management in parallel
- Result aggregation from concurrent calls
"""
import json
import logging
from typing import Any
import pytest
from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent names from the 05_multi_agent_orchestration_concurrency sample
PHYSICIST_AGENT_NAME: str = "PhysicistAgent"
CHEMIST_AGENT_NAME: str = "ChemistAgent"
# Configure logging
logging.basicConfig(level=logging.WARNING)
# Module-level markers
pytestmark = [
pytest.mark.sample("05_multi_agent_orchestration_concurrency"),
pytest.mark.integration_test,
pytest.mark.requires_dts,
]
class TestMultiAgentOrchestrationConcurrency:
"""Test suite for multi-agent orchestration with concurrency."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint = dts_endpoint
self.taskhub = worker_process["taskhub"]
# Create agent client and DTS client
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
# Create orchestration helper
self.orch_helper = OrchestrationHelper(self.dts_client)
def test_agents_registered(self):
"""Test that both agents are registered."""
physicist = self.agent_client.get_agent(PHYSICIST_AGENT_NAME)
chemist = self.agent_client.get_agent(CHEMIST_AGENT_NAME)
assert physicist is not None
assert physicist.name == PHYSICIST_AGENT_NAME
assert chemist is not None
assert chemist.name == CHEMIST_AGENT_NAME
def test_different_prompts(self):
"""Test concurrent orchestration with different prompts."""
prompts = [
"What is temperature?",
"Explain molecules.",
]
for prompt in prompts:
instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="multi_agent_concurrent_orchestration",
input=prompt,
)
metadata, output = self.orch_helper.wait_for_orchestration_with_output(
instance_id=instance_id,
timeout=120.0,
)
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
result = json.loads(output)
assert "physicist" in result
assert "chemist" in result
@@ -0,0 +1,95 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for multi-agent orchestration with conditionals.
Tests conditional orchestration patterns:
- Conditional branching in orchestrations
- Agent-based decision making
- Activity function execution
- Structured output handling
- Conditional routing based on agent responses
"""
import logging
from typing import Any
import pytest
from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Agent names from the 06_multi_agent_orchestration_conditionals sample
SPAM_AGENT_NAME: str = "SpamDetectionAgent"
EMAIL_AGENT_NAME: str = "EmailAssistantAgent"
# Configure logging
logging.basicConfig(level=logging.WARNING)
# Module-level markers
pytestmark = [
pytest.mark.sample("06_multi_agent_orchestration_conditionals"),
pytest.mark.integration_test,
pytest.mark.requires_dts,
]
class TestMultiAgentOrchestrationConditionals:
"""Test suite for multi-agent orchestration with conditionals."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = dts_endpoint
self.taskhub: str = str(worker_process["taskhub"])
# Create agent client and DTS client
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
# Create orchestration helper
self.orch_helper = OrchestrationHelper(self.dts_client)
def test_agents_registered(self):
"""Test that both agents are registered."""
spam_agent = self.agent_client.get_agent(SPAM_AGENT_NAME)
email_agent = self.agent_client.get_agent(EMAIL_AGENT_NAME)
assert spam_agent is not None
assert spam_agent.name == SPAM_AGENT_NAME
assert email_agent is not None
assert email_agent.name == EMAIL_AGENT_NAME
def test_conditional_branching(self):
"""Test that conditional branching works correctly."""
# Test with obvious spam
spam_payload = {
"email_id": "spam-001",
"email_content": "Buy cheap medications online! No prescription needed! Limited time offer!",
}
spam_instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="spam_detection_orchestration",
input=spam_payload,
)
# Test with legitimate email
legit_payload = {
"email_id": "legit-001",
"email_content": "Hi team, please review the attached document before our meeting tomorrow.",
}
legit_instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="spam_detection_orchestration",
input=legit_payload,
)
# Both should complete successfully (different branches)
spam_metadata = self.orch_helper.wait_for_orchestration(
instance_id=spam_instance_id,
timeout=120.0,
)
legit_metadata = self.orch_helper.wait_for_orchestration(
instance_id=legit_instance_id,
timeout=120.0,
)
assert spam_metadata.runtime_status == OrchestrationStatus.COMPLETED
assert legit_metadata.runtime_status == OrchestrationStatus.COMPLETED
@@ -0,0 +1,170 @@
# Copyright (c) Microsoft. All rights reserved.
"""Integration tests for single agent orchestration with human-in-the-loop.
Tests human-in-the-loop (HITL) patterns:
- External event waiting and handling
- Timeout handling in orchestrations
- Iterative refinement with human feedback
- Activity function integration
- Approval workflow patterns
"""
import logging
from typing import Any
import pytest
from dt_testutils import OrchestrationHelper, create_agent_client
from durabletask.client import OrchestrationStatus
# Constants from the 07_single_agent_orchestration_hitl sample
WRITER_AGENT_NAME: str = "WriterAgent"
HUMAN_APPROVAL_EVENT: str = "HumanApproval"
# Configure logging
logging.basicConfig(level=logging.WARNING)
# Module-level markers
pytestmark = [
pytest.mark.sample("07_single_agent_orchestration_hitl"),
pytest.mark.integration_test,
pytest.mark.requires_dts,
]
class TestSingleAgentOrchestrationHITL:
"""Test suite for single agent orchestration with human-in-the-loop."""
@pytest.fixture(autouse=True)
def setup(self, worker_process: dict[str, Any], dts_endpoint: str) -> None:
"""Setup test fixtures."""
self.endpoint: str = str(worker_process["endpoint"])
self.taskhub: str = str(worker_process["taskhub"])
logging.info(f"Using taskhub: {self.taskhub} at endpoint: {self.endpoint}")
# Create agent client and DTS client
self.dts_client, self.agent_client = create_agent_client(self.endpoint, self.taskhub)
# Create orchestration helper
self.orch_helper = OrchestrationHelper(self.dts_client)
def test_agent_registered(self):
"""Test that the Writer agent is registered."""
agent = self.agent_client.get_agent(WRITER_AGENT_NAME)
assert agent is not None
assert agent.name == WRITER_AGENT_NAME
def test_hitl_orchestration_with_approval(self):
"""Test HITL orchestration with immediate approval."""
payload = {
"topic": "The benefits of continuous learning",
"max_review_attempts": 3,
"approval_timeout_seconds": 60,
}
# Start the orchestration
instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
assert instance_id is not None
# Wait for orchestration to reach notification point
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
assert notification_received, "Failed to receive notification from orchestration"
# Send approval event
approval_data = {"approved": True, "feedback": ""}
self.orch_helper.raise_event(
instance_id=instance_id,
event_name=HUMAN_APPROVAL_EVENT,
event_data=approval_data,
)
# Wait for completion
metadata = self.orch_helper.wait_for_orchestration(
instance_id=instance_id,
timeout=90.0,
)
assert metadata is not None
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
def test_hitl_orchestration_with_rejection_and_feedback(self):
"""Test HITL orchestration with rejection and iterative refinement."""
payload = {
"topic": "Artificial Intelligence in healthcare",
"max_review_attempts": 3,
"approval_timeout_seconds": 60,
}
# Start the orchestration
instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
# Wait for orchestration to reach notification point
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
assert notification_received, "Failed to receive notification from orchestration"
# First rejection with feedback
rejection_data = {
"approved": False,
"feedback": "Please make it more concise and add specific examples.",
}
self.orch_helper.raise_event(
instance_id=instance_id,
event_name=HUMAN_APPROVAL_EVENT,
event_data=rejection_data,
)
# Wait for orchestration to refine and reach notification point again
notification_received = self.orch_helper.wait_for_notification(instance_id, timeout_seconds=90)
assert notification_received, "Failed to receive notification after refinement"
# Second approval
approval_data = {"approved": True, "feedback": ""}
self.orch_helper.raise_event(
instance_id=instance_id,
event_name=HUMAN_APPROVAL_EVENT,
event_data=approval_data,
)
# Wait for completion
metadata = self.orch_helper.wait_for_orchestration(
instance_id=instance_id,
timeout=90.0,
)
assert metadata is not None
assert metadata.runtime_status == OrchestrationStatus.COMPLETED
def test_hitl_orchestration_timeout(self):
"""Test HITL orchestration timeout behavior."""
payload = {
"topic": "Cloud computing fundamentals",
"max_review_attempts": 1,
"approval_timeout_seconds": 0.1, # Short timeout for testing
}
# Start the orchestration
instance_id = self.dts_client.schedule_new_orchestration(
orchestrator="content_generation_hitl_orchestration",
input=payload,
)
# Don't send any approval - let it timeout
# The orchestration should fail due to timeout
try:
metadata = self.orch_helper.wait_for_orchestration(
instance_id=instance_id,
timeout=90.0,
)
# If it completes, it should be failed status due to timeout
assert metadata.runtime_status == OrchestrationStatus.FAILED
except (RuntimeError, TimeoutError):
# Expected - orchestration should timeout and fail
pass
@@ -0,0 +1,300 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for AgentSessionId and DurableAgentThread."""
import pytest
from agent_framework import AgentThread
from agent_framework_durabletask._models import AgentSessionId, DurableAgentThread
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_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_parse_with_agent_name_override(self) -> None:
"""Test parsing @name@key format with agent_name parameter overrides the name."""
session_id = AgentSessionId.parse("@OriginalAgent@test-key-123", agent_name="OverriddenAgent")
assert session_id.name == "OverriddenAgent"
assert session_id.key == "test-key-123"
def test_parse_without_agent_name_uses_parsed_name(self) -> None:
"""Test parsing @name@key format without agent_name uses name from string."""
session_id = AgentSessionId.parse("@ParsedAgent@test-key-123")
assert session_id.name == "ParsedAgent"
assert session_id.key == "test-key-123"
def test_parse_plain_string_with_agent_name(self) -> None:
"""Test parsing plain string with agent_name uses entire string as key."""
session_id = AgentSessionId.parse("simple-thread-123", agent_name="TestAgent")
assert session_id.name == "TestAgent"
assert session_id.key == "simple-thread-123"
def test_parse_plain_string_without_agent_name_raises(self) -> None:
"""Test parsing plain string without agent_name raises ValueError."""
with pytest.raises(ValueError) as exc_info:
AgentSessionId.parse("simple-thread-123")
assert "Invalid agent session ID format" in str(exc_info.value)
class TestDurableAgentThread:
"""Test suite for DurableAgentThread."""
def test_init_with_session_id(self) -> None:
"""Test DurableAgentThread initialization with session ID."""
session_id = AgentSessionId(name="TestAgent", key="test-key")
thread = DurableAgentThread(session_id=session_id)
assert thread.session_id is not None
assert thread.session_id == session_id
def test_init_without_session_id(self) -> None:
"""Test DurableAgentThread initialization without session ID."""
thread = DurableAgentThread()
assert thread.session_id is None
def test_session_id_setter(self) -> None:
"""Test setting a session ID to an existing thread."""
thread = DurableAgentThread()
assert thread.session_id is None
session_id = AgentSessionId(name="TestAgent", key="test-key")
thread.session_id = session_id
assert thread.session_id is not None
assert thread.session_id == session_id
assert thread.session_id.name == "TestAgent"
def test_from_session_id(self) -> None:
"""Test creating DurableAgentThread from session ID."""
session_id = AgentSessionId(name="TestAgent", key="test-key")
thread = DurableAgentThread.from_session_id(session_id)
assert isinstance(thread, DurableAgentThread)
assert thread.session_id is not None
assert thread.session_id == session_id
assert thread.session_id.name == "TestAgent"
assert thread.session_id.key == "test-key"
def test_from_session_id_with_service_thread_id(self) -> None:
"""Test creating DurableAgentThread with service thread ID."""
session_id = AgentSessionId(name="TestAgent", key="test-key")
thread = DurableAgentThread.from_session_id(session_id, service_thread_id="service-123")
assert thread.session_id is not None
assert thread.session_id == session_id
assert thread.service_thread_id == "service-123"
async def test_serialize_with_session_id(self) -> None:
"""Test serialization includes session ID."""
session_id = AgentSessionId(name="TestAgent", key="test-key")
thread = DurableAgentThread(session_id=session_id)
serialized = await thread.serialize()
assert isinstance(serialized, dict)
assert "durable_session_id" in serialized
assert serialized["durable_session_id"] == "@TestAgent@test-key"
async def test_serialize_without_session_id(self) -> None:
"""Test serialization without session ID."""
thread = DurableAgentThread()
serialized = await thread.serialize()
assert isinstance(serialized, dict)
assert "durable_session_id" not in serialized
async def test_deserialize_with_session_id(self) -> None:
"""Test deserialization restores session ID."""
serialized = {
"service_thread_id": "thread-123",
"durable_session_id": "@TestAgent@test-key",
}
thread = await DurableAgentThread.deserialize(serialized)
assert isinstance(thread, DurableAgentThread)
assert thread.session_id is not None
assert thread.session_id.name == "TestAgent"
assert thread.session_id.key == "test-key"
assert thread.service_thread_id == "thread-123"
async def test_deserialize_without_session_id(self) -> None:
"""Test deserialization without session ID."""
serialized = {
"service_thread_id": "thread-456",
}
thread = await DurableAgentThread.deserialize(serialized)
assert isinstance(thread, DurableAgentThread)
assert thread.session_id is None
assert thread.service_thread_id == "thread-456"
async def test_round_trip_serialization(self) -> None:
"""Test round-trip serialization preserves session ID."""
session_id = AgentSessionId(name="TestAgent", key="test-key-789")
original = DurableAgentThread(session_id=session_id)
serialized = await original.serialize()
restored = await DurableAgentThread.deserialize(serialized)
assert isinstance(restored, DurableAgentThread)
assert restored.session_id is not None
assert restored.session_id.name == session_id.name
assert restored.session_id.key == session_id.key
async def test_deserialize_invalid_session_id_type(self) -> None:
"""Test deserialization with invalid session ID type raises error."""
serialized = {
"service_thread_id": "thread-123",
"durable_session_id": 12345, # Invalid type
}
with pytest.raises(ValueError, match="durable_session_id must be a string"):
await DurableAgentThread.deserialize(serialized)
class TestAgentThreadCompatibility:
"""Test suite for compatibility between AgentThread and DurableAgentThread."""
async def test_agent_thread_serialize(self) -> None:
"""Test that base AgentThread can be serialized."""
thread = AgentThread()
serialized = await thread.serialize()
assert isinstance(serialized, dict)
assert "service_thread_id" in serialized
async def test_agent_thread_deserialize(self) -> None:
"""Test that base AgentThread can be deserialized."""
thread = AgentThread()
serialized = await thread.serialize()
restored = await AgentThread.deserialize(serialized)
assert isinstance(restored, AgentThread)
assert restored.service_thread_id == thread.service_thread_id
async def test_durable_thread_is_agent_thread(self) -> None:
"""Test that DurableAgentThread is an AgentThread."""
thread = DurableAgentThread()
assert isinstance(thread, AgentThread)
assert isinstance(thread, DurableAgentThread)
class TestModelIntegration:
"""Test suite for integration between models."""
def test_session_id_string_format(self) -> None:
"""Test that AgentSessionId string format is consistent."""
session_id = AgentSessionId.with_random_key("AgentEntity")
session_id_str = str(session_id)
assert session_id_str.startswith("@AgentEntity@")
async def test_thread_with_session_preserves_on_serialization(self) -> None:
"""Test that thread with session ID preserves it through serialization."""
session_id = AgentSessionId(name="TestAgent", key="preserved-key")
thread = DurableAgentThread.from_session_id(session_id)
# Serialize and deserialize
serialized = await thread.serialize()
restored = await DurableAgentThread.deserialize(serialized)
# Session ID should be preserved
assert restored.session_id is not None
assert restored.session_id.name == "TestAgent"
assert restored.session_id.key == "preserved-key"
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,142 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAIAgentClient.
Focuses on critical client workflows: agent retrieval, protocol compliance, and integration.
Run with: pytest tests/test_client.py -v
"""
from unittest.mock import Mock
import pytest
from agent_framework import AgentProtocol
from agent_framework_durabletask import DurableAgentThread, DurableAIAgentClient
from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from agent_framework_durabletask._shim import DurableAIAgent
@pytest.fixture
def mock_grpc_client() -> Mock:
"""Create a mock TaskHubGrpcClient for testing."""
return Mock()
@pytest.fixture
def agent_client(mock_grpc_client: Mock) -> DurableAIAgentClient:
"""Create a DurableAIAgentClient with mock gRPC client."""
return DurableAIAgentClient(mock_grpc_client)
@pytest.fixture
def agent_client_with_custom_polling(mock_grpc_client: Mock) -> DurableAIAgentClient:
"""Create a DurableAIAgentClient with custom polling parameters."""
return DurableAIAgentClient(
mock_grpc_client,
max_poll_retries=15,
poll_interval_seconds=0.5,
)
class TestDurableAIAgentClientGetAgent:
"""Test core workflow: retrieving agents from the client."""
def test_get_agent_returns_durable_agent_shim(self, agent_client: DurableAIAgentClient) -> None:
"""Verify get_agent returns a DurableAIAgent instance."""
agent = agent_client.get_agent("assistant")
assert isinstance(agent, DurableAIAgent)
assert isinstance(agent, AgentProtocol)
def test_get_agent_shim_has_correct_name(self, agent_client: DurableAIAgentClient) -> None:
"""Verify retrieved agent has the correct name."""
agent = agent_client.get_agent("my_agent")
assert agent.name == "my_agent"
def test_get_agent_multiple_times_returns_new_instances(self, agent_client: DurableAIAgentClient) -> None:
"""Verify multiple get_agent calls return independent instances."""
agent1 = agent_client.get_agent("assistant")
agent2 = agent_client.get_agent("assistant")
assert agent1 is not agent2 # Different object instances
def test_get_agent_different_agents(self, agent_client: DurableAIAgentClient) -> None:
"""Verify client can retrieve multiple different agents."""
agent1 = agent_client.get_agent("agent1")
agent2 = agent_client.get_agent("agent2")
assert agent1.name == "agent1"
assert agent2.name == "agent2"
class TestDurableAIAgentClientIntegration:
"""Test integration scenarios between client and agent shim."""
def test_client_agent_has_working_run_method(self, agent_client: DurableAIAgentClient) -> None:
"""Verify agent from client has callable run method (even if not yet implemented)."""
agent = agent_client.get_agent("assistant")
assert hasattr(agent, "run")
assert callable(agent.run)
def test_client_agent_can_create_threads(self, agent_client: DurableAIAgentClient) -> None:
"""Verify agent from client can create DurableAgentThread instances."""
agent = agent_client.get_agent("assistant")
thread = agent.get_new_thread()
assert isinstance(thread, DurableAgentThread)
def test_client_agent_thread_with_parameters(self, agent_client: DurableAIAgentClient) -> None:
"""Verify agent can create threads with custom parameters."""
agent = agent_client.get_agent("assistant")
thread = agent.get_new_thread(service_thread_id="client-session-123")
assert isinstance(thread, DurableAgentThread)
assert thread.service_thread_id == "client-session-123"
class TestDurableAIAgentClientPollingConfiguration:
"""Test polling configuration parameters for DurableAIAgentClient."""
def test_client_uses_default_polling_parameters(self, agent_client: DurableAIAgentClient) -> None:
"""Verify client initializes with default polling parameters."""
assert agent_client.max_poll_retries == DEFAULT_MAX_POLL_RETRIES
assert agent_client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
def test_client_accepts_custom_polling_parameters(
self, agent_client_with_custom_polling: DurableAIAgentClient
) -> None:
"""Verify client accepts and stores custom polling parameters."""
assert agent_client_with_custom_polling.max_poll_retries == 15
assert agent_client_with_custom_polling.poll_interval_seconds == 0.5
def test_client_validates_max_poll_retries(self, mock_grpc_client: Mock) -> None:
"""Verify client validates and normalizes max_poll_retries."""
# Test with zero - should enforce minimum of 1
client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=0)
assert client.max_poll_retries == 1
# Test with negative - should enforce minimum of 1
client = DurableAIAgentClient(mock_grpc_client, max_poll_retries=-5)
assert client.max_poll_retries == 1
def test_client_validates_poll_interval_seconds(self, mock_grpc_client: Mock) -> None:
"""Verify client validates and normalizes poll_interval_seconds."""
# Test with zero - should use default
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=0)
assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
# Test with negative - should use default
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=-0.5)
assert client.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
# Test with valid float
client = DurableAIAgentClient(mock_grpc_client, poll_interval_seconds=2.5)
assert client.poll_interval_seconds == 2.5
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,217 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAgentState and related classes."""
from datetime import datetime
import pytest
from agent_framework_durabletask._durable_agent_state import (
DurableAgentState,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateTextContent,
)
from agent_framework_durabletask._models import RunRequest
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 handles auto-populated created_at from RunRequest.
When a RunRequest is created with None for created_at, RunRequest defaults it to
current UTC time. The resulting DurableAgentStateMessage should have this timestamp.
"""
run_request = RunRequest(
message="test message",
correlation_id="corr-run",
created_at=None, # RunRequest will default this to current time
)
durable_message = DurableAgentStateMessage.from_run_request(run_request)
# RunRequest auto-populates created_at, so it should not be None
assert durable_message.created_at is not 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=datetime(2024, 1, 15, 10, 30, 0),
)
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
class TestDurableAgentState:
"""Test suite for DurableAgentState."""
def test_schema_version(self) -> None:
"""Test that schema version is set correctly."""
state = DurableAgentState()
assert state.schema_version == "1.1.0"
def test_to_dict_serialization(self) -> None:
"""Test that to_dict produces correct structure."""
state = DurableAgentState()
data = state.to_dict()
assert "schemaVersion" in data
assert "data" in data
assert data["schemaVersion"] == "1.1.0"
assert "conversationHistory" in data["data"]
def test_from_dict_deserialization(self) -> None:
"""Test that from_dict restores state correctly."""
original_data = {
"schemaVersion": "1.1.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "test-123",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [
{
"role": "user",
"contents": [{"$type": "text", "text": "Hello"}],
}
],
}
]
},
}
state = DurableAgentState.from_dict(original_data)
assert state.schema_version == "1.1.0"
assert len(state.data.conversation_history) == 1
assert isinstance(state.data.conversation_history[0], DurableAgentStateRequest)
def test_round_trip_serialization(self) -> None:
"""Test that round-trip serialization preserves data."""
state = DurableAgentState()
state.data.conversation_history.append(
DurableAgentStateRequest(
correlation_id="test-456",
created_at=datetime.now(),
messages=[
DurableAgentStateMessage(
role="user",
contents=[DurableAgentStateTextContent(text="Test message")],
)
],
)
)
data = state.to_dict()
restored = DurableAgentState.from_dict(data)
assert restored.schema_version == state.schema_version
assert len(restored.data.conversation_history) == len(state.data.conversation_history)
assert restored.data.conversation_history[0].correlation_id == "test-456"
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,695 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for AgentEntity.
Run with: pytest tests/test_entities.py -v
"""
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any, TypeVar
from unittest.mock import AsyncMock, Mock
import pytest
from agent_framework import AgentResponse, AgentResponseUpdate, ChatMessage, Content, Role
from pydantic import BaseModel
from agent_framework_durabletask import (
AgentEntity,
AgentEntityStateProviderMixin,
DurableAgentState,
DurableAgentStateData,
DurableAgentStateMessage,
DurableAgentStateRequest,
DurableAgentStateTextContent,
RunRequest,
)
from agent_framework_durabletask._entities import DurableTaskEntityStateProvider
TState = TypeVar("TState")
class MockEntityContext:
"""Minimal durabletask EntityContext shim for tests."""
def __init__(self, initial_state: Any = None) -> None:
self._state = initial_state
def get_state(
self,
intended_type: type[TState] | None = None,
default: TState | None = None,
) -> Any:
del intended_type
if self._state is None:
return default
return self._state
def set_state(self, new_state: Any) -> None:
self._state = new_state
class _InMemoryStateProvider(AgentEntityStateProviderMixin):
"""Test-only state provider for AgentEntity."""
def __init__(self, *, thread_id: str, 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
def _make_entity(agent: Any, callback: Any = None, *, thread_id: str = "test-thread") -> AgentEntity:
return AgentEntity(agent, callback=callback, state_provider=_InMemoryStateProvider(thread_id=thread_id))
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) -> AgentResponse:
"""Create an AgentResponse with a single assistant message."""
message = (
ChatMessage(role="assistant", text=text) if text is not None else ChatMessage(role="assistant", contents=[])
)
return AgentResponse(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: AgentResponseUpdate,
context: Any,
) -> None:
await self.stream_mock(update, context)
async def on_agent_response(self, response: AgentResponse, 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 = _make_entity(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 = _make_entity(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 = _make_entity(agent1)
entity2 = _make_entity(agent2)
assert entity1.agent.__class__.__name__ == "AzureOpenAIAgent"
assert entity2.agent.__class__.__name__ == "CustomAgent"
class TestDurableTaskEntityStateProvider:
"""Tests for DurableTaskEntityStateProvider wrapper behavior and persistence wiring."""
def _make_durabletask_entity_provider(
self,
agent: Any,
*,
initial_state: dict[str, Any] | None = None,
) -> tuple[DurableTaskEntityStateProvider, MockEntityContext]:
"""Create a DurableTaskEntityStateProvider wired to an in-memory durabletask context."""
entity = DurableTaskEntityStateProvider()
ctx = MockEntityContext(initial_state)
# DurableEntity provides this hook; required for get_state/set_state to work in unit tests.
entity._initialize_entity_context(ctx) # type: ignore[attr-defined]
return entity, ctx
def test_reset_persists_cleared_state(self) -> None:
mock_agent = Mock()
existing_state = {
"schemaVersion": "1.0.0",
"data": {
"conversationHistory": [
{
"$type": "request",
"correlationId": "corr-existing-1",
"createdAt": "2024-01-01T00:00:00Z",
"messages": [{"role": "user", "contents": [{"$type": "text", "text": "msg1"}]}],
}
]
},
}
entity, ctx = self._make_durabletask_entity_provider(mock_agent, initial_state=existing_state)
entity.reset()
persisted = ctx.get_state(dict, default={})
assert isinstance(persisted, dict)
assert persisted["data"]["conversationHistory"] == []
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 = _make_entity(mock_agent)
result = await entity.run({
"message": "Test message",
"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, AgentResponse)
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 = [
AgentResponseUpdate(text="Hello"),
AgentResponseUpdate(text=" world"),
]
async def update_generator() -> AsyncIterator[AgentResponseUpdate]:
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 = _make_entity(mock_agent, callback=callback, thread_id="session-1")
result = await entity.run(
{
"message": "Tell me something",
"correlationId": "corr-stream-1",
},
)
assert isinstance(result, AgentResponse)
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 = _make_entity(mock_agent, callback=callback, thread_id="session-2")
result = await entity.run(
{
"message": "Hi",
"correlationId": "corr-final-1",
},
)
assert isinstance(result, AgentResponse)
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 = _make_entity(mock_agent)
await entity.run({"message": "User message", "correlationId": "corr-entity-2"})
# Should have 2 entries: 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 = _make_entity(mock_agent)
assert len(entity.state.data.conversation_history) == 0
await entity.run({"message": "Message 1", "correlationId": "corr-entity-3a"})
assert len(entity.state.data.conversation_history) == 2
await entity.run({"message": "Message 2", "correlationId": "corr-entity-3b"})
assert len(entity.state.data.conversation_history) == 4
await entity.run({"message": "Message 3", "correlationId": "corr-entity-3c"})
assert len(entity.state.data.conversation_history) == 6
async def test_run_requires_entity_thread_id(self) -> None:
"""Test that AgentEntity.run rejects missing entity thread identifiers."""
mock_agent = Mock()
mock_agent.run = AsyncMock(return_value=_agent_response("Response"))
entity = _make_entity(mock_agent, thread_id="")
with pytest.raises(ValueError, match="thread_id"):
await entity.run({"message": "Message", "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 = _make_entity(mock_agent)
# Send multiple messages
await entity.run({"message": "Message 1", "correlationId": "corr-entity-8a"})
await entity.run({"message": "Message 2", "correlationId": "corr-entity-8b"})
await entity.run({"message": "Message 3", "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 = _make_entity(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")],
)
],
),
]
entity.reset()
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 = _make_entity(mock_agent)
# Set up some initial state with conversation history
entity.state.data = DurableAgentStateData(conversation_history=[], extension_data={"some_key": "some_value"})
entity.reset()
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 = _make_entity(mock_agent)
entity.reset()
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 = _make_entity(mock_agent)
# Have a conversation
await entity.run({"message": "Message 1", "correlationId": "corr-entity-10a"})
await entity.run({"message": "Message 2", "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()
# Verify state after reset
assert entity.state.message_count == 0
assert len(entity.state.data.conversation_history) == 0
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 = _make_entity(mock_agent)
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-1"})
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, Content)
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 = _make_entity(mock_agent)
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-2"})
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, Content)
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 = _make_entity(mock_agent)
result = await entity.run({"message": "Message", "correlationId": "corr-entity-error-3"})
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, Content)
assert content.error_code == "TimeoutError"
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 = _make_entity(mock_agent)
result = await entity.run(
{"message": "Test message", "correlationId": "corr-entity-error-4"},
)
# Even on error, message info should be preserved
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
content = result.messages[0].contents[0]
assert isinstance(content, Content)
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 = _make_entity(mock_agent)
await entity.run({"message": "Message", "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 = _make_entity(mock_agent)
# Send multiple messages with different responses
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
await entity.run(
{"message": "Message 1", "correlationId": "corr-entity-history-2a"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run(
{"message": "Message 2", "correlationId": "corr-entity-history-2b"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
await entity.run(
{"message": "Message 3", "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 = _make_entity(mock_agent)
await entity.run(
{"message": "Message 1", "correlationId": "corr-entity-history-3a"},
)
await entity.run(
{"message": "Message 2", "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 = _make_entity(mock_agent)
request = RunRequest(
message="Test message",
role=Role.USER,
enable_tool_calls=True,
correlation_id="corr-runreq-1",
)
result = await entity.run(request)
assert isinstance(result, AgentResponse)
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 = _make_entity(mock_agent)
request_dict = {
"message": "Test message",
"role": "system",
"enable_tool_calls": False,
"correlationId": "corr-runreq-2",
}
result = await entity.run(request_dict)
assert isinstance(result, AgentResponse)
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 = _make_entity(mock_agent)
with pytest.raises(ValueError):
await entity.run("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 = _make_entity(mock_agent)
# Send as system role
request = RunRequest(
message="System message",
role=Role.SYSTEM,
correlation_id="corr-runreq-3",
)
await entity.run(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 = _make_entity(mock_agent)
request = RunRequest(
message="What is the answer?",
response_format=EntityStructuredResponse,
correlation_id="corr-runreq-4",
)
result = await entity.run(request)
assert isinstance(result, AgentResponse)
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 = _make_entity(mock_agent)
request = RunRequest(message="Test", enable_tool_calls=False, correlation_id="corr-runreq-5")
result = await entity.run(request)
assert isinstance(result, AgentResponse)
# Agent should have been called (tool disabling is framework-dependent)
mock_agent.run.assert_called_once()
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,571 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAgentExecutor implementations.
Focuses on critical behavioral flows for executor strategies.
Run with: pytest tests/test_executors.py -v
"""
import time
from typing import Any
from unittest.mock import Mock
import pytest
from agent_framework import AgentResponse, Role
from durabletask.entities import EntityInstanceId
from durabletask.task import Task
from pydantic import BaseModel
from agent_framework_durabletask import DurableAgentThread
from agent_framework_durabletask._constants import DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS
from agent_framework_durabletask._executors import (
ClientAgentExecutor,
DurableAgentTask,
OrchestrationAgentExecutor,
)
from agent_framework_durabletask._models import AgentSessionId, RunRequest
# Fixtures
@pytest.fixture
def mock_client() -> Mock:
"""Provide a mock client for ClientAgentExecutor tests."""
client = Mock()
client.signal_entity = Mock()
client.get_entity = Mock(return_value=None)
return client
@pytest.fixture
def mock_entity_task() -> Mock:
"""Provide a mock entity task."""
task = Mock(spec=Task)
task.is_complete = False
task.is_failed = False
return task
@pytest.fixture
def mock_orchestration_context(mock_entity_task: Mock) -> Mock:
"""Provide a mock orchestration context with call_entity configured."""
context = Mock()
context.call_entity = Mock(return_value=mock_entity_task)
return context
@pytest.fixture
def sample_run_request() -> RunRequest:
"""Provide a sample RunRequest for tests."""
return RunRequest(message="test message", correlation_id="test-123")
@pytest.fixture
def client_executor(mock_client: Mock) -> ClientAgentExecutor:
"""Provide a ClientAgentExecutor with minimal polling for fast tests."""
return ClientAgentExecutor(mock_client, max_poll_retries=1, poll_interval_seconds=0.01)
@pytest.fixture
def orchestration_executor(mock_orchestration_context: Mock) -> OrchestrationAgentExecutor:
"""Provide an OrchestrationAgentExecutor."""
return OrchestrationAgentExecutor(mock_orchestration_context)
@pytest.fixture
def successful_agent_response() -> dict[str, Any]:
"""Provide a successful agent response dictionary."""
return {
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": "Hello!"}]}],
"created_at": "2025-12-30T10:00:00Z",
}
@pytest.fixture
def configure_successful_entity_task(mock_entity_task: Mock) -> Any:
"""Provide a helper to configure mock_entity_task with a successful response."""
def _configure(response: dict[str, Any]) -> Mock:
mock_entity_task.is_failed = False
mock_entity_task.is_complete = False
mock_entity_task.get_result = Mock(return_value=response)
return mock_entity_task
return _configure
@pytest.fixture
def configure_failed_entity_task(mock_entity_task: Mock) -> Any:
"""Provide a helper to configure mock_entity_task with a failure."""
def _configure(exception: Exception) -> Mock:
mock_entity_task.is_failed = True
mock_entity_task.is_complete = True
mock_entity_task.get_exception = Mock(return_value=exception)
return mock_entity_task
return _configure
class TestExecutorThreadCreation:
"""Test that executors properly create DurableAgentThread with parameters."""
def test_client_executor_creates_durable_thread(self, mock_client: Mock) -> None:
"""Verify ClientAgentExecutor creates DurableAgentThread instances."""
executor = ClientAgentExecutor(mock_client)
thread = executor.get_new_thread("test_agent")
assert isinstance(thread, DurableAgentThread)
def test_client_executor_forwards_kwargs_to_thread(self, mock_client: Mock) -> None:
"""Verify ClientAgentExecutor forwards kwargs to DurableAgentThread creation."""
executor = ClientAgentExecutor(mock_client)
thread = executor.get_new_thread("test_agent", service_thread_id="client-123")
assert isinstance(thread, DurableAgentThread)
assert thread.service_thread_id == "client-123"
def test_orchestration_executor_creates_durable_thread(
self, orchestration_executor: OrchestrationAgentExecutor
) -> None:
"""Verify OrchestrationAgentExecutor creates DurableAgentThread instances."""
thread = orchestration_executor.get_new_thread("test_agent")
assert isinstance(thread, DurableAgentThread)
def test_orchestration_executor_forwards_kwargs_to_thread(
self, orchestration_executor: OrchestrationAgentExecutor
) -> None:
"""Verify OrchestrationAgentExecutor forwards kwargs to DurableAgentThread creation."""
thread = orchestration_executor.get_new_thread("test_agent", service_thread_id="orch-456")
assert isinstance(thread, DurableAgentThread)
assert thread.service_thread_id == "orch-456"
class TestClientAgentExecutorRun:
"""Test that ClientAgentExecutor.run_durable_agent works as implemented."""
def test_client_executor_run_returns_response(
self, client_executor: ClientAgentExecutor, sample_run_request: RunRequest
) -> None:
"""Verify ClientAgentExecutor.run_durable_agent returns AgentResponse (synchronous)."""
result = client_executor.run_durable_agent("test_agent", sample_run_request)
# Verify it returns an AgentResponse (synchronous, not a coroutine)
assert isinstance(result, AgentResponse)
assert result is not None
class TestClientAgentExecutorPollingConfiguration:
"""Test polling configuration parameters for ClientAgentExecutor."""
def test_executor_uses_default_polling_parameters(self, mock_client: Mock) -> None:
"""Verify executor initializes with default polling parameters."""
executor = ClientAgentExecutor(mock_client)
assert executor.max_poll_retries == DEFAULT_MAX_POLL_RETRIES
assert executor.poll_interval_seconds == DEFAULT_POLL_INTERVAL_SECONDS
def test_executor_accepts_custom_polling_parameters(self, mock_client: Mock) -> None:
"""Verify executor accepts and stores custom polling parameters."""
executor = ClientAgentExecutor(mock_client, max_poll_retries=20, poll_interval_seconds=0.5)
assert executor.max_poll_retries == 20
assert executor.poll_interval_seconds == 0.5
def test_executor_respects_custom_max_poll_retries(self, mock_client: Mock, sample_run_request: RunRequest) -> None:
"""Verify executor respects custom max_poll_retries during polling."""
# Create executor with only 2 retries
executor = ClientAgentExecutor(mock_client, max_poll_retries=2, poll_interval_seconds=0.01)
# Run the agent
result = executor.run_durable_agent("test_agent", sample_run_request)
# Verify it returns AgentResponse (should timeout after 2 attempts)
assert isinstance(result, AgentResponse)
# Verify get_entity was called 2 times (max_poll_retries)
assert mock_client.get_entity.call_count == 2
def test_executor_respects_custom_poll_interval(self, mock_client: Mock, sample_run_request: RunRequest) -> None:
"""Verify executor respects custom poll_interval_seconds during polling."""
# Create executor with very short interval
executor = ClientAgentExecutor(mock_client, max_poll_retries=3, poll_interval_seconds=0.01)
# Measure time taken
start = time.time()
result = executor.run_durable_agent("test_agent", sample_run_request)
elapsed = time.time() - start
# Should take roughly 3 * 0.01 = 0.03 seconds (plus overhead)
# Be generous with timing to avoid flakiness
assert elapsed < 0.2 # Should be quick with 0.01 interval
assert isinstance(result, AgentResponse)
class TestClientAgentExecutorFireAndForget:
"""Test fire-and-forget mode (wait_for_response=False) for ClientAgentExecutor."""
def test_fire_and_forget_returns_immediately(self, mock_client: Mock) -> None:
"""Verify wait_for_response=False returns immediately without polling."""
executor = ClientAgentExecutor(mock_client, max_poll_retries=10, poll_interval_seconds=0.1)
# Create a request with wait_for_response=False
request = RunRequest(message="test message", correlation_id="test-123", wait_for_response=False)
# Measure time taken
start = time.time()
result = executor.run_durable_agent("test_agent", request)
elapsed = time.time() - start
# Should return immediately without polling (elapsed time should be very small)
assert elapsed < 0.1 # Much faster than any polling would take
# Should return an AgentResponse
assert isinstance(result, AgentResponse)
# Should have signaled the entity but not polled
assert mock_client.signal_entity.call_count == 1
assert mock_client.get_entity.call_count == 0 # No polling occurred
def test_fire_and_forget_returns_empty_response(self, mock_client: Mock) -> None:
"""Verify wait_for_response=False returns an acceptance message with correlation ID."""
executor = ClientAgentExecutor(mock_client)
request = RunRequest(message="test message", correlation_id="test-456", wait_for_response=False)
result = executor.run_durable_agent("test_agent", request)
# Verify it contains an acceptance message
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
assert result.messages[0].role == Role.SYSTEM
# Check message contains key information
message_text = result.messages[0].text
assert "accepted" in message_text.lower()
assert "test-456" in message_text # Contains correlation ID
assert "background" in message_text.lower()
class TestOrchestrationAgentExecutorFireAndForget:
"""Test fire-and-forget mode for OrchestrationAgentExecutor."""
def test_orchestration_fire_and_forget_calls_signal_entity(self, mock_orchestration_context: Mock) -> None:
"""Verify wait_for_response=False calls signal_entity instead of call_entity."""
executor = OrchestrationAgentExecutor(mock_orchestration_context)
mock_orchestration_context.signal_entity = Mock()
request = RunRequest(message="test", correlation_id="test-123", wait_for_response=False)
result = executor.run_durable_agent("test_agent", request)
# Verify signal_entity was called and call_entity was not
assert mock_orchestration_context.signal_entity.call_count == 1
assert mock_orchestration_context.call_entity.call_count == 0
# Should still return a DurableAgentTask
assert isinstance(result, DurableAgentTask)
def test_orchestration_fire_and_forget_returns_completed_task(self, mock_orchestration_context: Mock) -> None:
"""Verify wait_for_response=False returns pre-completed DurableAgentTask."""
executor = OrchestrationAgentExecutor(mock_orchestration_context)
mock_orchestration_context.signal_entity = Mock()
request = RunRequest(message="test", correlation_id="test-456", wait_for_response=False)
result = executor.run_durable_agent("test_agent", request)
# Task should be immediately complete
assert isinstance(result, DurableAgentTask)
assert result.is_complete
def test_orchestration_fire_and_forget_returns_acceptance_response(self, mock_orchestration_context: Mock) -> None:
"""Verify wait_for_response=False returns acceptance response."""
executor = OrchestrationAgentExecutor(mock_orchestration_context)
mock_orchestration_context.signal_entity = Mock()
request = RunRequest(message="test", correlation_id="test-789", wait_for_response=False)
result = executor.run_durable_agent("test_agent", request)
# Get the result
response = result.get_result()
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert response.messages[0].role == Role.SYSTEM
assert "test-789" in response.messages[0].text
def test_orchestration_blocking_mode_calls_call_entity(self, mock_orchestration_context: Mock) -> None:
"""Verify wait_for_response=True uses call_entity as before."""
executor = OrchestrationAgentExecutor(mock_orchestration_context)
mock_orchestration_context.signal_entity = Mock()
request = RunRequest(message="test", correlation_id="test-abc", wait_for_response=True)
result = executor.run_durable_agent("test_agent", request)
# Verify call_entity was called and signal_entity was not
assert mock_orchestration_context.call_entity.call_count == 1
assert mock_orchestration_context.signal_entity.call_count == 0
# Should return a DurableAgentTask
assert isinstance(result, DurableAgentTask)
class TestOrchestrationAgentExecutorRun:
"""Test OrchestrationAgentExecutor.run_durable_agent implementation."""
def test_orchestration_executor_run_returns_durable_agent_task(
self, orchestration_executor: OrchestrationAgentExecutor, sample_run_request: RunRequest
) -> None:
"""Verify OrchestrationAgentExecutor.run_durable_agent returns DurableAgentTask."""
result = orchestration_executor.run_durable_agent("test_agent", sample_run_request)
assert isinstance(result, DurableAgentTask)
def test_orchestration_executor_calls_entity_with_correct_parameters(
self,
mock_orchestration_context: Mock,
orchestration_executor: OrchestrationAgentExecutor,
sample_run_request: RunRequest,
) -> None:
"""Verify call_entity is invoked with correct entity ID and request."""
orchestration_executor.run_durable_agent("test_agent", sample_run_request)
# Verify call_entity was called once
assert mock_orchestration_context.call_entity.call_count == 1
# Get the call arguments
call_args = mock_orchestration_context.call_entity.call_args
entity_id_arg = call_args[0][0]
operation_arg = call_args[0][1]
request_dict_arg = call_args[0][2]
# Verify entity ID
assert isinstance(entity_id_arg, EntityInstanceId)
assert entity_id_arg.entity == "dafx-test_agent"
# Verify operation name
assert operation_arg == "run"
# Verify request dict
assert request_dict_arg == sample_run_request.to_dict()
def test_orchestration_executor_uses_thread_session_id(
self,
mock_orchestration_context: Mock,
orchestration_executor: OrchestrationAgentExecutor,
sample_run_request: RunRequest,
) -> None:
"""Verify executor uses thread's session ID when provided."""
# Create thread with specific session ID
session_id = AgentSessionId(name="test_agent", key="specific-key-123")
thread = DurableAgentThread.from_session_id(session_id)
result = orchestration_executor.run_durable_agent("test_agent", sample_run_request, thread=thread)
# Verify call_entity was called with the specific key
call_args = mock_orchestration_context.call_entity.call_args
entity_id_arg = call_args[0][0]
assert entity_id_arg.key == "specific-key-123"
assert isinstance(result, DurableAgentTask)
class TestDurableAgentTask:
"""Test DurableAgentTask completion and response transformation."""
def test_durable_agent_task_transforms_successful_result(
self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any]
) -> None:
"""Verify DurableAgentTask converts successful entity result to AgentResponse."""
mock_entity_task = configure_successful_entity_task(successful_agent_response)
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion
task.on_child_completed(mock_entity_task)
assert task.is_complete
result = task.get_result()
assert isinstance(result, AgentResponse)
assert len(result.messages) == 1
assert result.messages[0].role == Role.ASSISTANT
def test_durable_agent_task_propagates_failure(self, configure_failed_entity_task: Any) -> None:
"""Verify DurableAgentTask propagates task failures."""
mock_entity_task = configure_failed_entity_task(ValueError("Entity error"))
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion with failure
task.on_child_completed(mock_entity_task)
assert task.is_complete
assert task.is_failed
# The exception is wrapped in TaskFailedError by the durabletask library
exception = task.get_exception()
assert exception is not None
def test_durable_agent_task_validates_response_format(self, configure_successful_entity_task: Any) -> None:
"""Verify DurableAgentTask validates response format when provided."""
response = {
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"answer": "42"}'}]}],
"created_at": "2025-12-30T10:00:00Z",
}
mock_entity_task = configure_successful_entity_task(response)
class TestResponse(BaseModel):
answer: str
task = DurableAgentTask(entity_task=mock_entity_task, response_format=TestResponse, correlation_id="test-123")
# Simulate child task completion
task.on_child_completed(mock_entity_task)
assert task.is_complete
result = task.get_result()
assert isinstance(result, AgentResponse)
def test_durable_agent_task_ignores_duplicate_completion(
self, configure_successful_entity_task: Any, successful_agent_response: dict[str, Any]
) -> None:
"""Verify DurableAgentTask ignores duplicate completion calls."""
mock_entity_task = configure_successful_entity_task(successful_agent_response)
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion twice
task.on_child_completed(mock_entity_task)
first_result = task.get_result()
task.on_child_completed(mock_entity_task)
second_result = task.get_result()
# Should be the same result, get_result should only be called once
assert first_result is second_result
assert mock_entity_task.get_result.call_count == 1
def test_durable_agent_task_fails_on_malformed_response(self, configure_successful_entity_task: Any) -> None:
"""Verify DurableAgentTask fails when entity returns malformed response data."""
# Use data that will cause AgentResponse.from_dict to fail
# Using a list instead of dict, or other invalid structure
mock_entity_task = configure_successful_entity_task("invalid string response")
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion with malformed data
task.on_child_completed(mock_entity_task)
assert task.is_complete
assert task.is_failed
def test_durable_agent_task_fails_on_invalid_response_format(self, configure_successful_entity_task: Any) -> None:
"""Verify DurableAgentTask fails when response doesn't match required format."""
response = {
"messages": [{"role": "assistant", "contents": [{"type": "text", "text": '{"wrong": "field"}'}]}],
"created_at": "2025-12-30T10:00:00Z",
}
mock_entity_task = configure_successful_entity_task(response)
class StrictResponse(BaseModel):
required_field: str
task = DurableAgentTask(entity_task=mock_entity_task, response_format=StrictResponse, correlation_id="test-123")
# Simulate child task completion with wrong format
task.on_child_completed(mock_entity_task)
assert task.is_complete
assert task.is_failed
def test_durable_agent_task_handles_empty_response(self, configure_successful_entity_task: Any) -> None:
"""Verify DurableAgentTask handles response with empty messages list."""
response: dict[str, str | list[Any]] = {
"messages": [],
"created_at": "2025-12-30T10:00:00Z",
}
mock_entity_task = configure_successful_entity_task(response)
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion
task.on_child_completed(mock_entity_task)
assert task.is_complete
result = task.get_result()
assert isinstance(result, AgentResponse)
assert len(result.messages) == 0
def test_durable_agent_task_handles_multiple_messages(self, configure_successful_entity_task: Any) -> None:
"""Verify DurableAgentTask correctly processes response with multiple messages."""
response = {
"messages": [
{"role": "assistant", "contents": [{"type": "text", "text": "First message"}]},
{"role": "assistant", "contents": [{"type": "text", "text": "Second message"}]},
],
"created_at": "2025-12-30T10:00:00Z",
}
mock_entity_task = configure_successful_entity_task(response)
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
# Simulate child task completion
task.on_child_completed(mock_entity_task)
assert task.is_complete
result = task.get_result()
assert isinstance(result, AgentResponse)
assert len(result.messages) == 2
assert result.messages[0].role == Role.ASSISTANT
assert result.messages[1].role == Role.ASSISTANT
def test_durable_agent_task_is_not_complete_initially(self, mock_entity_task: Mock) -> None:
"""Verify DurableAgentTask is not complete when first created."""
task = DurableAgentTask(entity_task=mock_entity_task, response_format=None, correlation_id="test-123")
assert not task.is_complete
assert not task.is_failed
def test_durable_agent_task_completes_with_complex_response_format(
self, configure_successful_entity_task: Any
) -> None:
"""Verify DurableAgentTask validates complex nested response formats correctly."""
response = {
"messages": [
{
"role": "assistant",
"contents": [
{
"type": "text",
"text": '{"name": "test", "count": 42, "items": ["a", "b", "c"]}',
}
],
}
],
"created_at": "2025-12-30T10:00:00Z",
}
mock_entity_task = configure_successful_entity_task(response)
class ComplexResponse(BaseModel):
name: str
count: int
items: list[str]
task = DurableAgentTask(
entity_task=mock_entity_task, response_format=ComplexResponse, correlation_id="test-123"
)
# Simulate child task completion
task.on_child_completed(mock_entity_task)
assert task.is_complete
assert not task.is_failed
result = task.get_result()
assert isinstance(result, AgentResponse)
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,310 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for data models (RunRequest)."""
import pytest
from agent_framework import Role
from pydantic import BaseModel
from agent_framework_durabletask._models import RunRequest
class ModuleStructuredResponse(BaseModel):
value: int
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")
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
def test_init_with_all_fields(self) -> None:
"""Test RunRequest initialization with all fields."""
schema = ModuleStructuredResponse
request = RunRequest(
message="Hello",
correlation_id="corr-002",
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
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]
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")
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
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",
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
def test_from_dict_with_defaults(self) -> None:
"""Test from_dict with minimal data."""
data = {"message": "Hello", "correlationId": "corr-006"}
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"
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",
"module": ModuleStructuredResponse.__module__,
"qualname": ModuleStructuredResponse.__qualname__,
},
"enable_tool_calls": False,
}
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
def test_from_dict_unknown_role_preserves_value(self) -> None:
"""Test from_dict keeps custom roles intact."""
data = {"message": "Test", "correlationId": "corr-009", "role": "reviewer"}
request = RunRequest.from_dict(data)
assert request.role.value == "reviewer"
assert request.role != Role.USER
def test_from_dict_empty_message(self) -> None:
"""Test from_dict with empty message."""
request = RunRequest.from_dict({"correlationId": "corr-010"})
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"})
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",
role=Role.SYSTEM,
response_format=ModuleStructuredResponse,
enable_tool_calls=False,
)
data = original.to_dict()
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
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",
response_format=ModuleStructuredResponse,
)
data = original.to_dict()
assert data["response_format"]["__response_schema_type__"] == "pydantic_model"
assert data["response_format"]["module"] == ModuleStructuredResponse.__module__
assert data["response_format"]["qualname"] == ModuleStructuredResponse.__qualname__
restored = RunRequest.from_dict(data)
assert restored.response_format is ModuleStructuredResponse
def test_round_trip_with_options(self) -> None:
"""Ensure options are preserved and response_format is deserialized."""
original = RunRequest(
message="Test",
correlation_id="corr-opts-1",
response_format=ModuleStructuredResponse,
enable_tool_calls=False,
options={
"response_format": ModuleStructuredResponse,
"enable_tool_calls": False,
"custom": "value",
},
)
data = original.to_dict()
assert data["options"]["custom"] == "value"
restored = RunRequest.from_dict(data)
assert restored.options is not None
assert restored.options["custom"] == "value"
assert restored.options["response_format"] is ModuleStructuredResponse
def test_init_with_correlationId(self) -> None:
"""Test RunRequest initialization with correlationId."""
request = RunRequest(message="Test message", 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")
data = request.to_dict()
assert data["message"] == "Test"
assert data["correlationId"] == "corr-456"
def test_from_dict_with_correlationId(self) -> None:
"""Test from_dict with correlationId."""
data = {"message": "Test", "correlationId": "corr-789"}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.correlation_id == "corr-789"
def test_round_trip_with_correlationId(self) -> None:
"""Test round-trip to_dict and from_dict with correlationId."""
original = RunRequest(
message="Test message",
role=Role.SYSTEM,
correlation_id="corr-124",
)
data = original.to_dict()
restored = RunRequest.from_dict(data)
assert restored.message == original.message
assert restored.role == original.role
assert restored.correlation_id == original.correlation_id
def test_init_with_orchestration_id(self) -> None:
"""Test RunRequest initialization with orchestration_id."""
request = RunRequest(
message="Test message",
correlation_id="corr-125",
orchestration_id="orch-123",
)
assert request.message == "Test message"
assert request.orchestration_id == "orch-123"
def test_to_dict_with_orchestration_id(self) -> None:
"""Test to_dict includes orchestrationId."""
request = RunRequest(
message="Test",
correlation_id="corr-126",
orchestration_id="orch-456",
)
data = request.to_dict()
assert data["message"] == "Test"
assert data["orchestrationId"] == "orch-456"
def test_to_dict_excludes_orchestration_id_when_none(self) -> None:
"""Test to_dict excludes orchestrationId when not set."""
request = RunRequest(
message="Test",
correlation_id="corr-127",
)
data = request.to_dict()
assert "orchestrationId" not in data
def test_from_dict_with_orchestration_id(self) -> None:
"""Test from_dict with orchestrationId."""
data = {
"message": "Test",
"correlationId": "corr-128",
"orchestrationId": "orch-789",
}
request = RunRequest.from_dict(data)
assert request.message == "Test"
assert request.orchestration_id == "orch-789"
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",
role=Role.SYSTEM,
correlation_id="corr-129",
orchestration_id="orch-123",
)
data = original.to_dict()
restored = RunRequest.from_dict(data)
assert restored.message == original.message
assert restored.role == original.role
assert restored.correlation_id == original.correlation_id
assert restored.orchestration_id == original.orchestration_id
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,98 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAIAgentOrchestrationContext.
Focuses on critical orchestration workflows: agent retrieval and integration.
Run with: pytest tests/test_orchestration_context.py -v
"""
from unittest.mock import Mock
import pytest
from agent_framework import AgentProtocol
from agent_framework_durabletask import DurableAgentThread
from agent_framework_durabletask._orchestration_context import DurableAIAgentOrchestrationContext
from agent_framework_durabletask._shim import DurableAIAgent
@pytest.fixture
def mock_orchestration_context() -> Mock:
"""Create a mock OrchestrationContext for testing."""
return Mock()
@pytest.fixture
def agent_context(mock_orchestration_context: Mock) -> DurableAIAgentOrchestrationContext:
"""Create a DurableAIAgentOrchestrationContext with mock context."""
return DurableAIAgentOrchestrationContext(mock_orchestration_context)
class TestDurableAIAgentOrchestrationContextGetAgent:
"""Test core workflow: retrieving agents from orchestration context."""
def test_get_agent_returns_durable_agent_shim(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
"""Verify get_agent returns a DurableAIAgent instance."""
agent = agent_context.get_agent("assistant")
assert isinstance(agent, DurableAIAgent)
assert isinstance(agent, AgentProtocol)
def test_get_agent_shim_has_correct_name(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
"""Verify retrieved agent has the correct name."""
agent = agent_context.get_agent("my_agent")
assert agent.name == "my_agent"
def test_get_agent_multiple_times_returns_new_instances(
self, agent_context: DurableAIAgentOrchestrationContext
) -> None:
"""Verify multiple get_agent calls return independent instances."""
agent1 = agent_context.get_agent("assistant")
agent2 = agent_context.get_agent("assistant")
assert agent1 is not agent2 # Different object instances
def test_get_agent_different_agents(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
"""Verify context can retrieve multiple different agents."""
agent1 = agent_context.get_agent("agent1")
agent2 = agent_context.get_agent("agent2")
assert agent1.name == "agent1"
assert agent2.name == "agent2"
class TestDurableAIAgentOrchestrationContextIntegration:
"""Test integration scenarios between orchestration context and agent shim."""
def test_orchestration_agent_has_working_run_method(
self, agent_context: DurableAIAgentOrchestrationContext
) -> None:
"""Verify agent from context has callable run method (even if not yet implemented)."""
agent = agent_context.get_agent("assistant")
assert hasattr(agent, "run")
assert callable(agent.run)
def test_orchestration_agent_can_create_threads(self, agent_context: DurableAIAgentOrchestrationContext) -> None:
"""Verify agent from context can create DurableAgentThread instances."""
agent = agent_context.get_agent("assistant")
thread = agent.get_new_thread()
assert isinstance(thread, DurableAgentThread)
def test_orchestration_agent_thread_with_parameters(
self, agent_context: DurableAIAgentOrchestrationContext
) -> None:
"""Verify agent can create threads with custom parameters."""
agent = agent_context.get_agent("assistant")
thread = agent.get_new_thread(service_thread_id="orch-session-456")
assert isinstance(thread, DurableAgentThread)
assert thread.service_thread_id == "orch-session-456"
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,213 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAIAgent shim and DurableAgentProvider.
Focuses on critical message normalization, delegation, and protocol compliance.
Run with: pytest tests/test_shim.py -v
"""
from typing import Any
from unittest.mock import Mock
import pytest
from agent_framework import AgentProtocol, ChatMessage
from pydantic import BaseModel
from agent_framework_durabletask import DurableAgentThread
from agent_framework_durabletask._executors import DurableAgentExecutor
from agent_framework_durabletask._models import RunRequest
from agent_framework_durabletask._shim import DurableAgentProvider, DurableAIAgent
class ResponseFormatModel(BaseModel):
"""Test Pydantic model for response format testing."""
result: str
@pytest.fixture
def mock_executor() -> Mock:
"""Create a mock executor for testing."""
mock = Mock(spec=DurableAgentExecutor)
mock.run_durable_agent = Mock(return_value=None)
mock.get_new_thread = Mock(return_value=DurableAgentThread())
# Mock get_run_request to create actual RunRequest objects
def create_run_request(
message: str,
options: dict[str, Any] | None = None,
) -> RunRequest:
import uuid
opts = dict(options) if options else {}
response_format = opts.pop("response_format", None)
enable_tool_calls = opts.pop("enable_tool_calls", True)
wait_for_response = opts.pop("wait_for_response", True)
return RunRequest(
message=message,
correlation_id=str(uuid.uuid4()),
response_format=response_format,
enable_tool_calls=enable_tool_calls,
wait_for_response=wait_for_response,
options=opts,
)
mock.get_run_request = Mock(side_effect=create_run_request)
return mock
@pytest.fixture
def test_agent(mock_executor: Mock) -> DurableAIAgent[Any]:
"""Create a test agent with mock executor."""
return DurableAIAgent(mock_executor, "test_agent")
class TestDurableAIAgentMessageNormalization:
"""Test that DurableAIAgent properly normalizes various message input types."""
def test_run_accepts_string_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and normalizes string messages."""
test_agent.run("Hello, world!")
mock_executor.run_durable_agent.assert_called_once()
# Verify agent_name and run_request were passed correctly as kwargs
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["agent_name"] == "test_agent"
assert kwargs["run_request"].message == "Hello, world!"
def test_run_accepts_chat_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and normalizes ChatMessage objects."""
chat_msg = ChatMessage(role="user", text="Test message")
test_agent.run(chat_msg)
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].message == "Test message"
def test_run_accepts_list_of_strings(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and joins list of strings."""
test_agent.run(["First message", "Second message"])
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].message == "First message\nSecond message"
def test_run_accepts_list_of_chat_messages(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run accepts and joins list of ChatMessage objects."""
messages = [
ChatMessage(role="user", text="Message 1"),
ChatMessage(role="assistant", text="Message 2"),
]
test_agent.run(messages)
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].message == "Message 1\nMessage 2"
def test_run_handles_none_message(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run handles None message gracefully."""
test_agent.run(None)
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].message == ""
def test_run_handles_empty_list(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run handles empty list gracefully."""
test_agent.run([])
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].message == ""
class TestDurableAIAgentParameterFlow:
"""Test that parameters flow correctly through the shim to executor."""
def test_run_forwards_thread_parameter(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run forwards thread parameter to executor."""
thread = DurableAgentThread(service_thread_id="test-thread")
test_agent.run("message", thread=thread)
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["thread"] == thread
def test_run_forwards_response_format(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify run forwards response_format parameter to executor."""
test_agent.run("message", options={"response_format": ResponseFormatModel})
mock_executor.run_durable_agent.assert_called_once()
_, kwargs = mock_executor.run_durable_agent.call_args
assert kwargs["run_request"].response_format == ResponseFormatModel
class TestDurableAIAgentProtocolCompliance:
"""Test that DurableAIAgent implements AgentProtocol correctly."""
def test_agent_implements_protocol(self, test_agent: DurableAIAgent[Any]) -> None:
"""Verify DurableAIAgent implements AgentProtocol."""
assert isinstance(test_agent, AgentProtocol)
def test_agent_has_required_properties(self, test_agent: DurableAIAgent[Any]) -> None:
"""Verify DurableAIAgent has all required AgentProtocol properties."""
assert hasattr(test_agent, "id")
assert hasattr(test_agent, "name")
assert hasattr(test_agent, "display_name")
assert hasattr(test_agent, "description")
def test_agent_id_defaults_to_name(self, mock_executor: Mock) -> None:
"""Verify agent id defaults to name when not provided."""
agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent")
assert agent.id == "my_agent"
assert agent.name == "my_agent"
def test_agent_id_can_be_customized(self, mock_executor: Mock) -> None:
"""Verify agent id can be set independently from name."""
agent: DurableAIAgent[Any] = DurableAIAgent(mock_executor, "my_agent", agent_id="custom-id")
assert agent.id == "custom-id"
assert agent.name == "my_agent"
class TestDurableAIAgentThreadManagement:
"""Test thread creation and management."""
def test_get_new_thread_delegates_to_executor(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify get_new_thread delegates to executor."""
mock_thread = DurableAgentThread()
mock_executor.get_new_thread.return_value = mock_thread
thread = test_agent.get_new_thread()
mock_executor.get_new_thread.assert_called_once_with("test_agent")
assert thread == mock_thread
def test_get_new_thread_forwards_kwargs(self, test_agent: DurableAIAgent[Any], mock_executor: Mock) -> None:
"""Verify get_new_thread forwards kwargs to executor."""
mock_thread = DurableAgentThread(service_thread_id="thread-123")
mock_executor.get_new_thread.return_value = mock_thread
test_agent.get_new_thread(service_thread_id="thread-123")
mock_executor.get_new_thread.assert_called_once()
_, kwargs = mock_executor.get_new_thread.call_args
assert kwargs["service_thread_id"] == "thread-123"
class TestDurableAgentProviderInterface:
"""Test that DurableAgentProvider defines the correct interface."""
def test_provider_cannot_be_instantiated(self) -> None:
"""Verify DurableAgentProvider is abstract and cannot be instantiated."""
with pytest.raises(TypeError):
DurableAgentProvider() # type: ignore[abstract]
def test_provider_defines_get_agent_method(self) -> None:
"""Verify DurableAgentProvider defines get_agent abstract method."""
assert hasattr(DurableAgentProvider, "get_agent")
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
@@ -0,0 +1,168 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for DurableAIAgentWorker.
Focuses on critical worker flows: agent registration, validation, callbacks, and lifecycle.
"""
from unittest.mock import Mock
import pytest
from agent_framework_durabletask import DurableAIAgentWorker
@pytest.fixture
def mock_grpc_worker() -> Mock:
"""Create a mock TaskHubGrpcWorker for testing."""
mock = Mock()
mock.add_entity = Mock(return_value="dafx-test_agent")
mock.start = Mock()
mock.stop = Mock()
return mock
@pytest.fixture
def mock_agent() -> Mock:
"""Create a mock agent for testing."""
agent = Mock()
agent.name = "test_agent"
return agent
@pytest.fixture
def agent_worker(mock_grpc_worker: Mock) -> DurableAIAgentWorker:
"""Create a DurableAIAgentWorker with mock worker."""
return DurableAIAgentWorker(mock_grpc_worker)
class TestDurableAIAgentWorkerRegistration:
"""Test agent registration behavior."""
def test_add_agent_accepts_agent_with_name(
self, agent_worker: DurableAIAgentWorker, mock_agent: Mock, mock_grpc_worker: Mock
) -> None:
"""Verify that agents with names can be registered."""
agent_worker.add_agent(mock_agent)
# Verify entity was registered with underlying worker
mock_grpc_worker.add_entity.assert_called_once()
# Verify agent name is tracked
assert "test_agent" in agent_worker.registered_agent_names
def test_add_agent_rejects_agent_without_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify that agents without names are rejected."""
agent_no_name = Mock()
agent_no_name.name = None
with pytest.raises(ValueError, match="Agent must have a name"):
agent_worker.add_agent(agent_no_name)
def test_add_agent_rejects_empty_name(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify that agents with empty names are rejected."""
agent_empty_name = Mock()
agent_empty_name.name = ""
with pytest.raises(ValueError, match="Agent must have a name"):
agent_worker.add_agent(agent_empty_name)
def test_add_agent_rejects_duplicate_names(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
"""Verify duplicate agent names are not allowed."""
agent_worker.add_agent(mock_agent)
# Try to register another agent with the same name
duplicate_agent = Mock()
duplicate_agent.name = "test_agent"
with pytest.raises(ValueError, match="already registered"):
agent_worker.add_agent(duplicate_agent)
def test_registered_agent_names_tracks_multiple_agents(self, agent_worker: DurableAIAgentWorker) -> None:
"""Verify registered_agent_names tracks all registered agents."""
agent1 = Mock()
agent1.name = "agent1"
agent2 = Mock()
agent2.name = "agent2"
agent3 = Mock()
agent3.name = "agent3"
agent_worker.add_agent(agent1)
agent_worker.add_agent(agent2)
agent_worker.add_agent(agent3)
registered = agent_worker.registered_agent_names
assert "agent1" in registered
assert "agent2" in registered
assert "agent3" in registered
assert len(registered) == 3
class TestDurableAIAgentWorkerCallbacks:
"""Test callback configuration behavior."""
def test_worker_level_callback_accepted(self, mock_grpc_worker: Mock) -> None:
"""Verify worker-level callback can be set."""
mock_callback = Mock()
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=mock_callback)
assert agent_worker is not None
def test_agent_level_callback_accepted(self, agent_worker: DurableAIAgentWorker, mock_agent: Mock) -> None:
"""Verify agent-level callback can be set during registration."""
mock_callback = Mock()
# Should not raise exception
agent_worker.add_agent(mock_agent, callback=mock_callback)
assert "test_agent" in agent_worker.registered_agent_names
def test_none_callback_accepted(self, mock_grpc_worker: Mock, mock_agent: Mock) -> None:
"""Verify None callback is valid (no callbacks required)."""
agent_worker = DurableAIAgentWorker(mock_grpc_worker, callback=None)
agent_worker.add_agent(mock_agent, callback=None)
assert "test_agent" in agent_worker.registered_agent_names
class TestDurableAIAgentWorkerLifecycle:
"""Test worker lifecycle behavior."""
def test_start_delegates_to_underlying_worker(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Verify start() delegates to wrapped worker."""
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
def test_stop_delegates_to_underlying_worker(
self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock
) -> None:
"""Verify stop() delegates to wrapped worker."""
agent_worker.stop()
mock_grpc_worker.stop.assert_called_once()
def test_start_works_with_no_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
"""Verify worker can start even with no agents registered."""
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
def test_start_works_with_multiple_agents(self, agent_worker: DurableAIAgentWorker, mock_grpc_worker: Mock) -> None:
"""Verify worker can start with multiple agents registered."""
agent1 = Mock()
agent1.name = "agent1"
agent2 = Mock()
agent2.name = "agent2"
agent_worker.add_agent(agent1)
agent_worker.add_agent(agent2)
agent_worker.start()
mock_grpc_worker.start.assert_called_once()
assert len(agent_worker.registered_agent_names) == 2
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260123"
version = "1.0.0b260116"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"

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