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>
This commit is contained in:
Laveesh Rohra
2026-01-02 13:13:37 -08:00
committed by GitHub
co-authored by dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Chris Sunil Dutta budgetboardingai Copilot Evan Mattson SergeyMenshykh copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> SergeyMenshykh Tao Chen Kurt Korolev Dmitry Mark Wallace Copilot rogerbarreto dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Eduard van Valkenburg Dmytro Struk Jose Luis Latorre Millas Jacob Alber Richard Ortega 刘邦学AI Stephen Toub Nico Möller Chris Gillum Giles Odigwe Phillip Hoff Ege Ozan Özyedek samueljohnsiby Evan Mattson Hao Luo Victor Dibia stephentoub Jacob Viau SuperKenVery Sunil Dutta Syrine Chelly
parent a02527f00a
commit a5b36dc379
182 changed files with 7454 additions and 2462 deletions
+11 -9
View File
@@ -11,13 +11,13 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="11.0.0" />
<PackageVersion Include="Anthropic" Version="12.0.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.1.0" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0-beta.440" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
@@ -100,7 +100,7 @@
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.11" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
@@ -112,19 +112,21 @@
<PackageVersion Include="Microsoft.Bot.ObjectModel.PowerFx" Version="1.2025.1106.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.5.0-build.20251008-1002" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.16.2" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.16.2-preview.1" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.16.2" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.16.2-preview.1" />
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.9.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Sdk" Version="2.0.7" />
<!-- Redis -->
<PackageVersion Include="StackExchange.Redis" Version="2.10.1" />
<!-- Test -->
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net8.0'" Version="8.0.22" />
+1
View File
@@ -33,6 +33,7 @@
<Project Path="samples/AzureFunctions/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/AzureFunctions/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251204.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251204.1</PackageVersion>
<GitTag>1.0.0-preview.251204.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251219.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251219.1</PackageVersion>
<GitTag>1.0.0-preview.251219.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -19,12 +19,12 @@ internal sealed class AgenticUIAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -20,12 +20,12 @@ internal sealed class PredictiveStateUpdatesAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -19,12 +19,12 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>ReliableStreaming</AssemblyName>
<RootNamespace>ReliableStreaming</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Redis for reliable streaming -->
<ItemGroup>
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,320 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Functions.Worker;
using Microsoft.DurableTask.Client;
using Microsoft.Extensions.Logging;
namespace ReliableStreaming;
/// <summary>
/// HTTP trigger functions for reliable streaming of durable agent responses.
/// </summary>
/// <remarks>
/// This class exposes two endpoints:
/// <list type="bullet">
/// <item>
/// <term>Create</term>
/// <description>Starts an agent run and streams responses. The response format depends on the
/// <c>Accept</c> header: <c>text/plain</c> returns raw text (ideal for terminals), while
/// <c>text/event-stream</c> or any other value returns Server-Sent Events (SSE).</description>
/// </item>
/// <item>
/// <term>Stream</term>
/// <description>Resumes a stream from a cursor position, enabling reliable message delivery</description>
/// </item>
/// </list>
/// </remarks>
public sealed class FunctionTriggers
{
private readonly RedisStreamResponseHandler _streamHandler;
private readonly ILogger<FunctionTriggers> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FunctionTriggers"/> class.
/// </summary>
/// <param name="streamHandler">The Redis stream handler for reading/writing agent responses.</param>
/// <param name="logger">The logger instance.</param>
public FunctionTriggers(RedisStreamResponseHandler streamHandler, ILogger<FunctionTriggers> logger)
{
this._streamHandler = streamHandler;
this._logger = logger;
}
/// <summary>
/// Creates a new agent session, starts an agent run with the provided prompt,
/// and streams the response back to the client.
/// </summary>
/// <remarks>
/// <para>
/// The response format depends on the <c>Accept</c> header:
/// <list type="bullet">
/// <item><c>text/plain</c>: Returns raw text output, ideal for terminal display with curl</item>
/// <item><c>text/event-stream</c> or other: Returns Server-Sent Events (SSE) with cursor support</item>
/// </list>
/// </para>
/// <para>
/// The response includes an <c>x-conversation-id</c> header containing the conversation ID.
/// For SSE responses, clients can use this conversation ID to resume the stream if disconnected
/// by calling the <see cref="StreamAsync"/> endpoint with the conversation ID and the last received cursor.
/// </para>
/// <para>
/// Each SSE event contains the following fields:
/// <list type="bullet">
/// <item><c>id</c>: The Redis stream entry ID (use as cursor for resumption)</item>
/// <item><c>event</c>: Either "message" for content or "done" for stream completion</item>
/// <item><c>data</c>: The text content of the response chunk</item>
/// </list>
/// </para>
/// </remarks>
/// <param name="request">The HTTP request containing the prompt in the body.</param>
/// <param name="durableClient">The Durable Task client for signaling agents.</param>
/// <param name="context">The function invocation context.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A streaming response in the format specified by the Accept header.</returns>
[Function(nameof(CreateAsync))]
public async Task<IActionResult> CreateAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "agent/create")] HttpRequest request,
[DurableClient] DurableTaskClient durableClient,
FunctionContext context,
CancellationToken cancellationToken)
{
// Read the prompt from the request body
string prompt = await new StreamReader(request.Body).ReadToEndAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(prompt))
{
return new BadRequestObjectResult("Request body must contain a prompt.");
}
AIAgent agentProxy = durableClient.AsDurableAgentProxy(context, "TravelPlanner");
// Create a new agent thread
AgentThread thread = agentProxy.GetNewThread();
AgentThreadMetadata metadata = thread.GetService<AgentThreadMetadata>()
?? throw new InvalidOperationException("Failed to get AgentThreadMetadata from new thread.");
this._logger.LogInformation("Creating new agent session: {ConversationId}", metadata.ConversationId);
// Run the agent in the background (fire-and-forget)
DurableAgentRunOptions options = new() { IsFireAndForget = true };
await agentProxy.RunAsync(prompt, thread, options, cancellationToken);
this._logger.LogInformation("Agent run started for session: {ConversationId}", metadata.ConversationId);
// Check Accept header to determine response format
// text/plain = raw text output (ideal for terminals)
// text/event-stream or other = SSE format (supports resumption)
string? acceptHeader = request.Headers.Accept.FirstOrDefault();
bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true;
return await this.StreamToClientAsync(
conversationId: metadata.ConversationId!, cursor: null, useSseFormat, request.HttpContext, cancellationToken);
}
/// <summary>
/// Resumes streaming from a specific cursor position for an existing session.
/// </summary>
/// <remarks>
/// <para>
/// Use this endpoint to resume a stream after disconnection. Pass the conversation ID
/// (from the <c>x-conversation-id</c> response header) and the last received cursor
/// (Redis stream entry ID) to continue from where you left off.
/// </para>
/// <para>
/// If no cursor is provided, streaming starts from the beginning of the stream.
/// This allows clients to replay the entire response if needed.
/// </para>
/// <para>
/// The response format depends on the <c>Accept</c> header:
/// <list type="bullet">
/// <item><c>text/plain</c>: Returns raw text output, ideal for terminal display with curl</item>
/// <item><c>text/event-stream</c> or other: Returns Server-Sent Events (SSE) with cursor support</item>
/// </list>
/// </para>
/// </remarks>
/// <param name="request">The HTTP request. Use the <c>cursor</c> query parameter to specify the cursor position.</param>
/// <param name="conversationId">The conversation ID to stream from.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A streaming response in the format specified by the Accept header.</returns>
[Function(nameof(StreamAsync))]
public async Task<IActionResult> StreamAsync(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "agent/stream/{conversationId}")] HttpRequest request,
string conversationId,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(conversationId))
{
return new BadRequestObjectResult("Conversation ID is required.");
}
// Get the cursor from query string (optional)
string? cursor = request.Query["cursor"].FirstOrDefault();
this._logger.LogInformation(
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
conversationId,
cursor ?? "(beginning)");
// Check Accept header to determine response format
// text/plain = raw text output (ideal for terminals)
// text/event-stream or other = SSE format (supports cursor-based resumption)
string? acceptHeader = request.Headers.Accept.FirstOrDefault();
bool useSseFormat = acceptHeader?.Contains("text/plain", StringComparison.OrdinalIgnoreCase) != true;
return await this.StreamToClientAsync(conversationId, cursor, useSseFormat, request.HttpContext, cancellationToken);
}
/// <summary>
/// Streams chunks from the Redis stream to the HTTP response.
/// </summary>
/// <param name="conversationId">The conversation ID to stream from.</param>
/// <param name="cursor">Optional cursor to resume from. If null, streams from the beginning.</param>
/// <param name="useSseFormat">True to use SSE format, false for plain text.</param>
/// <param name="httpContext">The HTTP context for writing the response.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An empty result after streaming completes.</returns>
private async Task<IActionResult> StreamToClientAsync(
string conversationId,
string? cursor,
bool useSseFormat,
HttpContext httpContext,
CancellationToken cancellationToken)
{
// Set response headers based on format
httpContext.Response.Headers.ContentType = useSseFormat
? "text/event-stream"
: "text/plain; charset=utf-8";
httpContext.Response.Headers.CacheControl = "no-cache";
httpContext.Response.Headers.Connection = "keep-alive";
httpContext.Response.Headers["x-conversation-id"] = conversationId;
// Disable response buffering if supported
httpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
try
{
await foreach (StreamChunk chunk in this._streamHandler.ReadStreamAsync(
conversationId,
cursor,
cancellationToken))
{
if (chunk.Error != null)
{
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
break;
}
if (chunk.IsDone)
{
await WriteEndOfStreamAsync(httpContext.Response, chunk.EntryId, useSseFormat, cancellationToken);
break;
}
if (chunk.Text != null)
{
await WriteChunkAsync(httpContext.Response, chunk, useSseFormat, cancellationToken);
}
}
}
catch (OperationCanceledException)
{
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
}
return new EmptyResult();
}
/// <summary>
/// Writes a text chunk to the response.
/// </summary>
private static async Task WriteChunkAsync(
HttpResponse response,
StreamChunk chunk,
bool useSseFormat,
CancellationToken cancellationToken)
{
if (useSseFormat)
{
await WriteSSEEventAsync(response, "message", chunk.Text!, chunk.EntryId);
}
else
{
await response.WriteAsync(chunk.Text!, cancellationToken);
}
await response.Body.FlushAsync(cancellationToken);
}
/// <summary>
/// Writes an end-of-stream marker to the response.
/// </summary>
private static async Task WriteEndOfStreamAsync(
HttpResponse response,
string entryId,
bool useSseFormat,
CancellationToken cancellationToken)
{
if (useSseFormat)
{
await WriteSSEEventAsync(response, "done", "[DONE]", entryId);
}
else
{
await response.WriteAsync("\n", cancellationToken);
}
await response.Body.FlushAsync(cancellationToken);
}
/// <summary>
/// Writes an error message to the response.
/// </summary>
private static async Task WriteErrorAsync(
HttpResponse response,
string error,
bool useSseFormat,
CancellationToken cancellationToken)
{
if (useSseFormat)
{
await WriteSSEEventAsync(response, "error", error, null);
}
else
{
await response.WriteAsync($"\n[Error: {error}]\n", cancellationToken);
}
await response.Body.FlushAsync(cancellationToken);
}
/// <summary>
/// Writes a Server-Sent Event to the response stream.
/// </summary>
private static async Task WriteSSEEventAsync(
HttpResponse response,
string eventType,
string data,
string? id)
{
StringBuilder sb = new();
// Include the ID if provided (used as cursor for resumption)
if (!string.IsNullOrEmpty(id))
{
sb.AppendLine($"id: {id}");
}
sb.AppendLine($"event: {eventType}");
sb.AppendLine($"data: {data}");
sb.AppendLine(); // Empty line marks end of event
await response.WriteAsync(sb.ToString());
}
}
@@ -0,0 +1,100 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams.
// It exposes two HTTP endpoints:
// 1. Create - Starts an agent run and streams responses back via Server-Sent Events (SSE)
// 2. Stream - Resumes a stream from a specific cursor position, enabling reliable message delivery
//
// This pattern is inspired by OpenAI's background mode for the Responses API, which allows clients
// to disconnect and reconnect to ongoing agent responses without losing messages.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using OpenAI.Chat;
using ReliableStreaming;
using StackExchange.Redis;
// Get the Azure OpenAI endpoint and deployment name from environment variables.
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
// Get Redis connection string from environment variable.
string redisConnectionString = Environment.GetEnvironmentVariable("REDIS_CONNECTION_STRING")
?? "localhost:6379";
// Get the Redis stream TTL from environment variable (default: 10 minutes).
int redisStreamTtlMinutes = int.TryParse(
Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES"),
out int ttlMinutes) ? ttlMinutes : 10;
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = System.Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
AzureOpenAIClient client = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
// Travel Planner agent instructions - designed to produce longer responses for demonstrating streaming.
const string TravelPlannerName = "TravelPlanner";
const string TravelPlannerInstructions =
"""
You are an expert travel planner who creates detailed, personalized travel itineraries.
When asked to plan a trip, you should:
1. Create a comprehensive day-by-day itinerary
2. Include specific recommendations for activities, restaurants, and attractions
3. Provide practical tips for each destination
4. Consider weather and local events when making recommendations
5. Include estimated times and logistics between activities
Always use the available tools to get current weather forecasts and local events
for the destination to make your recommendations more relevant and timely.
Format your response with clear headings for each day and include emoji icons
to make the itinerary easy to scan and visually appealing.
""";
// Configure the function app to host the AI agent.
FunctionsApplicationBuilder builder = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options =>
{
// Define the Travel Planner agent with tools for weather and events
options.AddAIAgentFactory(TravelPlannerName, sp =>
{
return client.GetChatClient(deploymentName).CreateAIAgent(
instructions: TravelPlannerInstructions,
name: TravelPlannerName,
services: sp,
tools: [
AIFunctionFactory.Create(TravelTools.GetWeatherForecast),
AIFunctionFactory.Create(TravelTools.GetLocalEvents),
]);
});
});
// Register Redis connection as a singleton
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
ConnectionMultiplexer.Connect(redisConnectionString));
// Register the Redis stream response handler - this captures agent responses
// and publishes them to Redis Streams for reliable delivery.
// Registered as both the concrete type (for FunctionTriggers) and the interface (for the agent framework).
builder.Services.AddSingleton(sp =>
new RedisStreamResponseHandler(
sp.GetRequiredService<IConnectionMultiplexer>(),
TimeSpan.FromMinutes(redisStreamTtlMinutes)));
builder.Services.AddSingleton<IAgentResponseHandler>(sp =>
sp.GetRequiredService<RedisStreamResponseHandler>());
using IHost app = builder.Build();
app.Run();
@@ -0,0 +1,264 @@
# Reliable Streaming with Redis
This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams as a message broker. It enables clients to disconnect and reconnect to ongoing agent responses without losing messages, inspired by [OpenAI's background mode](https://platform.openai.com/docs/guides/background) for the Responses API.
## Key Concepts Demonstrated
- **Reliable message delivery**: Agent responses are persisted to Redis Streams, allowing clients to resume from any point
- **Content negotiation**: Use `Accept: text/plain` for raw terminal output, or `Accept: text/event-stream` for SSE format
- **Server-Sent Events (SSE)**: Standard streaming format that works with `curl`, browsers, and most HTTP clients
- **Cursor-based resumption**: Each SSE event includes an `id` field that can be used to resume the stream
- **Fire-and-forget agent invocation**: The agent runs in the background while the client streams from Redis via an HTTP trigger function
## Environment Setup
See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
### Additional Requirements: Redis
This sample requires a Redis instance. Start a local Redis instance using Docker:
```bash
docker run -d --name redis -p 6379:6379 redis:latest
```
To verify Redis is running:
```bash
docker ps | grep redis
```
## Running the Sample
Start the Azure Functions host:
```bash
func start
```
### 1. Test Streaming with curl
Open a new terminal and start a travel planning request. Use the `-i` flag to see response headers (including the conversation ID) and `Accept: text/plain` for raw text output:
**Bash (Linux/macOS/WSL):**
```bash
curl -i -N -X POST http://localhost:7071/api/agent/create \
-H "Content-Type: text/plain" \
-H "Accept: text/plain" \
-d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around."
```
**PowerShell:**
```powershell
curl -i -N -X POST http://localhost:7071/api/agent/create `
-H "Content-Type: text/plain" `
-H "Accept: text/plain" `
-d "Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around."
```
You'll first see the response headers, including:
```text
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
...
```
Then the agent's response will stream to your terminal in chunks, similar to a ChatGPT-style experience (though not character-by-character).
> **Note:** The `-N` flag in curl disables output buffering, which is essential for seeing the stream in real-time. The `-i` flag includes the HTTP headers in the output.
### 2. Demonstrate Stream Interruption and Resumption
This is the key feature of reliable streaming! Follow these steps to see it in action:
#### Step 1: Start a stream and note the conversation ID
Run the curl command from step 1. Watch for the `x-conversation-id` header in the response - **copy this value**, you'll need it to resume.
```text
x-conversation-id: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
```
#### Step 2: Interrupt the stream
While the agent is still generating text, press **`Ctrl+C`** to interrupt the stream. The agent continues running in the background - your messages are being saved to Redis!
#### Step 3: Resume the stream
Use the conversation ID you copied to resume streaming from where you left off. Include the `Accept: text/plain` header to get raw text output:
**Bash (Linux/macOS/WSL):**
```bash
# Replace with your actual conversation ID from the x-conversation-id header
CONVERSATION_ID="@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890"
curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}"
```
**PowerShell:**
```powershell
# Replace with your actual conversation ID from the x-conversation-id header
$conversationId = "@dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890"
curl -N -H "Accept: text/plain" "http://localhost:7071/api/agent/stream/$conversationId"
```
You'll see the **entire response replayed from the beginning**, including the parts you already received before interrupting.
#### Step 4 (Advanced): Resume from a specific cursor
If you're using SSE format, each event includes an `id` field that you can use as a cursor to resume from a specific point:
```bash
# Resume from a specific cursor position
curl -N "http://localhost:7071/api/agent/stream/${CONVERSATION_ID}?cursor=1734567890123-0"
```
### 3. Alternative: SSE Format for Programmatic Clients
If you need the full Server-Sent Events format with cursors for resumable streaming, use `Accept: text/event-stream` (or omit the Accept header):
```bash
curl -i -N -X POST http://localhost:7071/api/agent/create \
-H "Content-Type: text/plain" \
-H "Accept: text/event-stream" \
-d "Plan a 7-day trip to Tokyo, Japan."
```
This returns SSE-formatted events with `id`, `event`, and `data` fields:
```text
id: 1734567890123-0
event: message
data: # 7-Day Tokyo Adventure
id: 1734567890124-0
event: message
data: ## Day 1: Arrival and Exploration
id: 1734567890999-0
event: done
data: [DONE]
```
The `id` field is the Redis stream entry ID - use it as the `cursor` parameter to resume from that exact point.
### Understanding the Response Headers
| Header | Description |
|--------|-------------|
| `x-conversation-id` | The conversation ID (session key). Use this to resume the stream. |
| `Content-Type` | Either `text/plain` or `text/event-stream` depending on your `Accept` header. |
| `Cache-Control` | Set to `no-cache` to prevent caching of the stream. |
## Architecture Overview
```text
┌─────────────┐ POST /agent/create ┌─────────────────────┐
│ Client │ (Accept: text/plain or SSE)│ Azure Functions │
│ (curl) │ ──────────────────────────► │ (FunctionTriggers) │
└─────────────┘ └──────────┬──────────┘
▲ │
│ Text or SSE stream Signal Entity
│ │
│ ▼
│ ┌─────────────────────┐
│ │ AgentEntity │
│ │ (Durable Entity) │
│ └──────────┬──────────┘
│ │
│ IAgentResponseHandler
│ │
│ ▼
│ ┌─────────────────────┐
│ │ RedisStreamResponse │
│ │ Handler │
│ └──────────┬──────────┘
│ │
│ XADD (write)
│ │
│ ▼
│ ┌─────────────────────┐
└─────────── XREAD (poll) ────────── │ Redis Streams │
│ (Durable Log) │
└─────────────────────┘
```
### Data Flow
1. **Client sends prompt**: The `Create` endpoint receives the prompt and generates a new agent thread.
2. **Agent invoked**: The durable entity (`AgentEntity`) is signaled to run the travel planner agent. This is fire-and-forget from the HTTP request's perspective.
3. **Responses captured**: As the agent generates responses, `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentRunResponseUpdate` and publishes it to a Redis Stream keyed by session ID.
4. **Client polls Redis**: The HTTP response streams events by polling the Redis Stream. For SSE format, each event includes the Redis entry ID as the `id` field.
5. **Resumption**: If the client disconnects, it can call the `Stream` endpoint with the conversation ID (from the `x-conversation-id` header) and optionally the last received cursor to resume from that point.
## Message Delivery Guarantees
This sample provides **at-least-once delivery** with the following characteristics:
- **Durability**: Messages are persisted to Redis Streams with configurable TTL (default: 10 minutes).
- **Ordering**: Messages are delivered in order within a session.
- **Resumption**: Clients can resume from any point using cursor-based pagination.
- **Replay**: Clients can replay the entire stream by omitting the cursor.
### Important Considerations
- **No exactly-once delivery**: If a client disconnects exactly when receiving a message, it may receive that message again upon resumption. Clients should handle duplicate messages idempotently.
- **TTL expiration**: Streams expire after the configured TTL. Clients cannot resume streams that have expired.
- **Redis guarantees**: Redis streams are backed by Redis persistence mechanisms (RDB/AOF). Ensure your Redis instance is configured for durability as needed.
## When to Use These Patterns
The patterns demonstrated in this sample are ideal for:
- **Long-running agent tasks**: When agent responses take minutes to complete (e.g., deep research, complex planning)
- **Unreliable network connections**: Mobile apps, unstable WiFi, or connections that may drop
- **Resumable experiences**: Users should be able to close and reopen an app without losing context
- **Background processing**: When you want to fire off a task and check on it later
These patterns may be overkill for:
- **Simple, fast responses**: If responses complete in a few seconds, standard streaming is simpler
- **Stateless interactions**: If there's no need to resume or replay conversations
- **Very high throughput**: Redis adds latency; for maximum throughput, direct streaming may be better
## Configuration
| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `REDIS_CONNECTION_STRING` | Redis connection string | `localhost:6379` |
| `REDIS_STREAM_TTL_MINUTES` | How long streams are retained after last write | `10` |
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | (required) |
| `AZURE_OPENAI_DEPLOYMENT` | Azure OpenAI deployment name | (required) |
| `AZURE_OPENAI_KEY` | API key (optional, uses Azure CLI auth if not set) | (optional) |
## Cleanup
To stop and remove the Redis Docker containers:
```bash
docker stop redis
docker rm redis
```
## Disclaimer
> ⚠️ **This sample is for illustration purposes only and is not intended to be production-ready.**
>
> A production implementation should consider:
>
> - Redis cluster configuration for high availability
> - Authentication and authorization for the streaming endpoints
> - Rate limiting and abuse prevention
> - Monitoring and alerting for stream health
> - Graceful handling of Redis failures
@@ -0,0 +1,213 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Runtime.CompilerServices;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using StackExchange.Redis;
namespace ReliableStreaming;
/// <summary>
/// Represents a chunk of data read from a Redis stream.
/// </summary>
/// <param name="EntryId">The Redis stream entry ID (can be used as a cursor for resumption).</param>
/// <param name="Text">The text content of the chunk, or null if this is a completion/error marker.</param>
/// <param name="IsDone">True if this chunk marks the end of the stream.</param>
/// <param name="Error">An error message if something went wrong, or null otherwise.</param>
public readonly record struct StreamChunk(string EntryId, string? Text, bool IsDone, string? Error);
/// <summary>
/// An implementation of <see cref="IAgentResponseHandler"/> that publishes agent response updates
/// to Redis Streams for reliable delivery. This enables clients to disconnect and reconnect
/// to ongoing agent responses without losing messages.
/// </summary>
/// <remarks>
/// <para>
/// Redis Streams provide a durable, append-only log that supports consumer groups and message
/// acknowledgment. This implementation uses auto-generated IDs (which are timestamp-based)
/// as sequence numbers, allowing clients to resume from any point in the stream.
/// </para>
/// <para>
/// Each agent session gets its own Redis Stream, keyed by session ID. The stream entries
/// contain text chunks extracted from <see cref="AgentRunResponseUpdate"/> objects.
/// </para>
/// </remarks>
public sealed class RedisStreamResponseHandler : IAgentResponseHandler
{
private const int MaxEmptyReads = 300; // 5 minutes at 1 second intervals
private const int PollIntervalMs = 1000;
private readonly IConnectionMultiplexer _redis;
private readonly TimeSpan _streamTtl;
/// <summary>
/// Initializes a new instance of the <see cref="RedisStreamResponseHandler" /> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="streamTtl">The time-to-live for stream entries. Streams will expire after this duration of inactivity.</param>
public RedisStreamResponseHandler(IConnectionMultiplexer redis, TimeSpan streamTtl)
{
this._redis = redis;
this._streamTtl = streamTtl;
}
/// <inheritdoc/>
public async ValueTask OnStreamingResponseUpdateAsync(
IAsyncEnumerable<AgentRunResponseUpdate> messageStream,
CancellationToken cancellationToken)
{
// Get the current session ID from the DurableAgentContext
// This is set by the AgentEntity before invoking the response handler
DurableAgentContext? context = DurableAgentContext.Current;
if (context is null)
{
throw new InvalidOperationException(
"DurableAgentContext.Current is not set. This handler must be used within a durable agent context.");
}
// Get conversation ID from the current thread context, which is only available in the context of
// a durable agent execution.
string conversationId = context.CurrentThread.GetService<AgentThreadMetadata>()?.ConversationId
?? throw new InvalidOperationException("Unable to determine conversation ID from the current thread.");
string streamKey = GetStreamKey(conversationId);
IDatabase db = this._redis.GetDatabase();
int sequenceNumber = 0;
await foreach (AgentRunResponseUpdate update in messageStream.WithCancellation(cancellationToken))
{
// Extract just the text content - this avoids serialization round-trip issues
string text = update.Text;
// Only publish non-empty text chunks
if (!string.IsNullOrEmpty(text))
{
// Create the stream entry with the text and metadata
NameValueEntry[] entries =
[
new NameValueEntry("text", text),
new NameValueEntry("sequence", sequenceNumber++),
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
];
// Add to the Redis Stream with auto-generated ID (timestamp-based)
await db.StreamAddAsync(streamKey, entries);
// Refresh the TTL on each write to keep the stream alive during active streaming
await db.KeyExpireAsync(streamKey, this._streamTtl);
}
}
// Add a sentinel entry to mark the end of the stream
NameValueEntry[] endEntries =
[
new NameValueEntry("text", ""),
new NameValueEntry("sequence", sequenceNumber),
new NameValueEntry("timestamp", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()),
new NameValueEntry("done", "true"),
];
await db.StreamAddAsync(streamKey, endEntries);
// Set final TTL - the stream will be cleaned up after this duration
await db.KeyExpireAsync(streamKey, this._streamTtl);
}
/// <inheritdoc/>
public ValueTask OnAgentResponseAsync(AgentRunResponse message, CancellationToken cancellationToken)
{
// This handler is optimized for streaming responses.
// For non-streaming responses, we don't need to store in Redis since
// the response is returned directly to the caller.
return ValueTask.CompletedTask;
}
/// <summary>
/// Reads chunks from a Redis stream for the given session, yielding them as they become available.
/// </summary>
/// <param name="conversationId">The conversation ID to read from.</param>
/// <param name="cursor">Optional cursor to resume from. If null, reads from the beginning.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of stream chunks.</returns>
public async IAsyncEnumerable<StreamChunk> ReadStreamAsync(
string conversationId,
string? cursor,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
string streamKey = GetStreamKey(conversationId);
IDatabase db = this._redis.GetDatabase();
string startId = string.IsNullOrEmpty(cursor) ? "0-0" : cursor;
int emptyReadCount = 0;
bool hasSeenData = false;
while (!cancellationToken.IsCancellationRequested)
{
StreamEntry[]? entries = null;
string? errorMessage = null;
try
{
entries = await db.StreamReadAsync(streamKey, startId, count: 100);
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
if (errorMessage != null)
{
yield return new StreamChunk(startId, null, false, errorMessage);
yield break;
}
// entries is guaranteed to be non-null if errorMessage is null
if (entries!.Length == 0)
{
if (!hasSeenData)
{
emptyReadCount++;
if (emptyReadCount >= MaxEmptyReads)
{
yield return new StreamChunk(
startId,
null,
false,
$"Stream not found or timed out after {MaxEmptyReads * PollIntervalMs / 1000} seconds");
yield break;
}
}
await Task.Delay(PollIntervalMs, cancellationToken);
continue;
}
hasSeenData = true;
foreach (StreamEntry entry in entries)
{
startId = entry.Id.ToString();
string? text = entry["text"];
string? done = entry["done"];
if (done == "true")
{
yield return new StreamChunk(startId, null, true, null);
yield break;
}
if (!string.IsNullOrEmpty(text))
{
yield return new StreamChunk(startId, text, false, null);
}
}
}
}
/// <summary>
/// Gets the Redis Stream key for a given conversation ID.
/// </summary>
/// <param name="conversationId">The conversation ID.</param>
/// <returns>The Redis Stream key.</returns>
internal static string GetStreamKey(string conversationId) => $"agent-stream:{conversationId}";
}
@@ -0,0 +1,161 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
namespace ReliableStreaming;
/// <summary>
/// Mock travel tools that return hardcoded data for demonstration purposes.
/// In a real application, these would call actual weather and events APIs.
/// </summary>
internal static class TravelTools
{
/// <summary>
/// Gets a weather forecast for a destination on a specific date.
/// Returns mock weather data for demonstration purposes.
/// </summary>
/// <param name="destination">The destination city or location.</param>
/// <param name="date">The date for the forecast (e.g., "2025-01-15" or "next Monday").</param>
/// <returns>A weather forecast summary.</returns>
[Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")]
public static string GetWeatherForecast(string destination, string date)
{
// Mock weather data based on destination for realistic responses
Dictionary<string, (string condition, int highF, int lowF)> weatherByRegion = new(StringComparer.OrdinalIgnoreCase)
{
["Tokyo"] = ("Partly cloudy with a chance of light rain", 58, 45),
["Paris"] = ("Overcast with occasional drizzle", 52, 41),
["New York"] = ("Clear and cold", 42, 28),
["London"] = ("Foggy morning, clearing in afternoon", 48, 38),
["Sydney"] = ("Sunny and warm", 82, 68),
["Rome"] = ("Sunny with light breeze", 62, 48),
["Barcelona"] = ("Partly sunny", 59, 47),
["Amsterdam"] = ("Cloudy with light rain", 46, 38),
["Dubai"] = ("Sunny and hot", 85, 72),
["Singapore"] = ("Tropical thunderstorms in afternoon", 88, 77),
["Bangkok"] = ("Hot and humid, afternoon showers", 91, 78),
["Los Angeles"] = ("Sunny and pleasant", 72, 55),
["San Francisco"] = ("Morning fog, afternoon sun", 62, 52),
["Seattle"] = ("Rainy with breaks", 48, 40),
["Miami"] = ("Warm and sunny", 78, 65),
["Honolulu"] = ("Tropical paradise weather", 82, 72),
};
// Find a matching destination or use a default
(string condition, int highF, int lowF) forecast = ("Partly cloudy", 65, 50);
foreach (KeyValuePair<string, (string, int, int)> entry in weatherByRegion)
{
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
{
forecast = entry.Value;
break;
}
}
return $"""
Weather forecast for {destination} on {date}:
Conditions: {forecast.condition}
High: {forecast.highF}°F ({(forecast.highF - 32) * 5 / 9}°C)
Low: {forecast.lowF}°F ({(forecast.lowF - 32) * 5 / 9}°C)
Recommendation: {GetWeatherRecommendation(forecast.condition)}
""";
}
/// <summary>
/// Gets local events happening at a destination around a specific date.
/// Returns mock event data for demonstration purposes.
/// </summary>
/// <param name="destination">The destination city or location.</param>
/// <param name="date">The date to search for events (e.g., "2025-01-15" or "next week").</param>
/// <returns>A list of local events and activities.</returns>
[Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")]
public static string GetLocalEvents(string destination, string date)
{
// Mock events data based on destination
Dictionary<string, string[]> eventsByCity = new(StringComparer.OrdinalIgnoreCase)
{
["Tokyo"] = [
"🎭 Kabuki Theater Performance at Kabukiza Theatre - Traditional Japanese drama",
"🌸 Winter Illuminations at Yoyogi Park - Spectacular light displays",
"🍜 Ramen Festival at Tokyo Station - Sample ramen from across Japan",
"🎮 Gaming Expo at Tokyo Big Sight - Latest video games and technology",
],
["Paris"] = [
"🎨 Impressionist Exhibition at Musée d'Orsay - Extended evening hours",
"🍷 Wine Tasting Tour in Le Marais - Local sommelier guided",
"🎵 Jazz Night at Le Caveau de la Huchette - Historic jazz club",
"🥐 French Pastry Workshop - Learn from master pâtissiers",
],
["New York"] = [
"🎭 Broadway Show: Hamilton - Limited engagement performances",
"🏀 Knicks vs Lakers at Madison Square Garden",
"🎨 Modern Art Exhibit at MoMA - New installations",
"🍕 Pizza Walking Tour of Brooklyn - Artisan pizzerias",
],
["London"] = [
"👑 Royal Collection Exhibition at Buckingham Palace",
"🎭 West End Musical: The Phantom of the Opera",
"🍺 Craft Beer Festival at Brick Lane",
"🎪 Winter Wonderland at Hyde Park - Rides and markets",
],
["Sydney"] = [
"🏄 Pro Surfing Competition at Bondi Beach",
"🎵 Opera at Sydney Opera House - La Bohème",
"🦘 Wildlife Night Safari at Taronga Zoo",
"🍽️ Harbor Dinner Cruise with fireworks",
],
["Rome"] = [
"🏛️ After-Hours Vatican Tour - Skip the crowds",
"🍝 Pasta Making Class in Trastevere",
"🎵 Classical Concert at Borghese Gallery",
"🍷 Wine Tasting in Roman Cellars",
],
};
// Find events for the destination or use generic events
string[] events = [
"🎭 Local theater performance",
"🍽️ Food and wine festival",
"🎨 Art gallery opening",
"🎵 Live music at local venues",
];
foreach (KeyValuePair<string, string[]> entry in eventsByCity)
{
if (destination.Contains(entry.Key, StringComparison.OrdinalIgnoreCase))
{
events = entry.Value;
break;
}
}
string eventList = string.Join("\n• ", events);
return $"""
Local events in {destination} around {date}:
• {eventList}
💡 Tip: Book popular events in advance as they may sell out quickly!
""";
}
private static string GetWeatherRecommendation(string condition)
{
// Use case-insensitive comparison instead of ToLowerInvariant() to satisfy CA1308
return condition switch
{
string c when c.Contains("rain", StringComparison.OrdinalIgnoreCase) || c.Contains("drizzle", StringComparison.OrdinalIgnoreCase) =>
"Bring an umbrella and waterproof jacket. Consider indoor activities for backup.",
string c when c.Contains("fog", StringComparison.OrdinalIgnoreCase) =>
"Morning visibility may be limited. Plan outdoor sightseeing for afternoon.",
string c when c.Contains("cold", StringComparison.OrdinalIgnoreCase) =>
"Layer up with warm clothing. Hot drinks and cozy cafés recommended.",
string c when c.Contains("hot", StringComparison.OrdinalIgnoreCase) || c.Contains("warm", StringComparison.OrdinalIgnoreCase) =>
"Stay hydrated and use sunscreen. Plan strenuous activities for cooler morning hours.",
string c when c.Contains("thunder", StringComparison.OrdinalIgnoreCase) || c.Contains("storm", StringComparison.OrdinalIgnoreCase) =>
"Keep an eye on weather updates. Have indoor alternatives ready.",
_ => "Pleasant conditions expected. Great day for outdoor exploration!"
};
}
}
@@ -0,0 +1,21 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information",
"ReliableStreaming": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,12 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT": "<AZURE_OPENAI_DEPLOYMENT>",
"REDIS_CONNECTION_STRING": "localhost:6379",
"REDIS_STREAM_TTL_MINUTES": "10"
}
}
+1
View File
@@ -9,6 +9,7 @@ This directory contains samples for Azure Functions.
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including external event handling for human approval.
- **[06_LongRunningTools](06_LongRunningTools)**: A sample that demonstrates how agents can start and interact with durable orchestrations from tool calls to enable long-running tool scenarios.
- **[07_AgentAsMcpTool](07_AgentAsMcpTool)**: A sample that demonstrates how to configure durable AI agents to be accessible as Model Context Protocol (MCP) tools.
- **[08_ReliableStreaming](08_ReliableStreaming)**: A sample that demonstrates how to implement reliable streaming for durable agents using Redis Streams, enabling clients to disconnect and reconnect without losing messages.
## Running the Samples
@@ -22,17 +22,17 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -22,17 +22,17 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -35,18 +35,18 @@ internal sealed class StatefulAgent<TState> : DelegatingAIAgent
}
/// <inheritdoc />
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
/// <inheritdoc />
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -17,17 +17,17 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
this._jsonSerializerOptions = jsonSerializerOptions;
}
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken)
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -34,7 +34,7 @@ namespace SampleApp
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new CustomAgentThread(serializedThread, jsonSerializerOptions);
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
// Create a thread if the user didn't supply one.
thread ??= this.GetNewThread();
@@ -45,7 +45,7 @@ namespace SampleApp
}
// Clone the input messages and turn them into response messages with upper case text.
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList();
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the thread of the input and output messages.
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
@@ -58,7 +58,7 @@ namespace SampleApp
};
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// Create a thread if the user didn't supply one.
thread ??= this.GetNewThread();
@@ -69,7 +69,7 @@ namespace SampleApp
}
// Clone the input messages and turn them into response messages with upper case text.
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.DisplayName).ToList();
List<ChatMessage> responseMessages = CloneAndToUpperCase(messages, this.Name).ToList();
// Notify the thread of the input and output messages.
await typedThread.MessageStore.AddMessagesAsync(messages.Concat(responseMessages), cancellationToken);
@@ -79,7 +79,7 @@ namespace SampleApp
yield return new AgentRunResponseUpdate
{
AgentId = this.Id,
AuthorName = this.DisplayName,
AuthorName = message.AuthorName,
Role = ChatRole.Assistant,
Contents = message.Contents,
ResponseId = Guid.NewGuid().ToString("N"),
@@ -88,7 +88,7 @@ namespace SampleApp
}
}
private static IEnumerable<ChatMessage> CloneAndToUpperCase(IEnumerable<ChatMessage> messages, string agentName) => messages.Select(x =>
private static IEnumerable<ChatMessage> CloneAndToUpperCase(IEnumerable<ChatMessage> messages, string? agentName) => messages.Select(x =>
{
// Clone the message and update its author to be the agent.
var messageClone = x.Clone();
@@ -87,10 +87,10 @@ public class OpenAIChatClientAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunAsync(messages, thread, options, cancellationToken);
protected sealed override Task<AgentRunResponse> RunCoreAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunStreamingAsync(messages, thread, options, cancellationToken);
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<Microsoft.Extensions.AI.ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
}
@@ -105,10 +105,10 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public sealed override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunAsync(messages, thread, options, cancellationToken);
protected sealed override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreAsync(messages, thread, options, cancellationToken);
/// <inheritdoc/>
public sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunStreamingAsync(messages, thread, options, cancellationToken);
protected sealed override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
base.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
}
@@ -35,7 +35,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251125.1" />
@@ -35,7 +35,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.4" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251125.1" />
@@ -48,9 +48,9 @@ public class WeatherForecastAgent : DelegatingAIAgent
{
}
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
var response = await base.RunAsync(messages, thread, options, cancellationToken);
var response = await base.RunCoreAsync(messages, thread, options, cancellationToken);
// If the agent returned a valid structured output response
// we might be able to enhance the response with an adaptive card.
@@ -30,7 +30,6 @@ internal sealed class A2AAgent : AIAgent
private readonly string? _id;
private readonly string? _name;
private readonly string? _description;
private readonly string? _displayName;
private readonly ILogger _logger;
/// <summary>
@@ -40,9 +39,8 @@ internal sealed class A2AAgent : AIAgent
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="displayName">The display name of the agent.</param>
/// <param name="loggerFactory">Optional logger factory to use for logging.</param>
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null)
public A2AAgent(A2AClient a2aClient, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null)
{
_ = Throw.IfNull(a2aClient);
@@ -50,7 +48,6 @@ internal sealed class A2AAgent : AIAgent
this._id = id;
this._name = name;
this._description = description;
this._displayName = displayName;
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<A2AAgent>();
}
@@ -71,7 +68,7 @@ internal sealed class A2AAgent : AIAgent
=> new A2AAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
@@ -134,7 +131,7 @@ internal sealed class A2AAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
_ = Throw.IfNull(messages);
@@ -203,9 +200,6 @@ internal sealed class A2AAgent : AIAgent
/// <inheritdoc/>
public override string? Name => this._name ?? base.Name;
/// <inheritdoc/>
public override string DisplayName => this._displayName ?? base.DisplayName;
/// <inheritdoc/>
public override string? Description => this._description ?? base.Description;
@@ -33,9 +33,8 @@ public static class A2AClientExtensions
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The the name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="displayName">The display name of the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static AIAgent GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, string? displayName = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, displayName, loggerFactory);
public static AIAgent GetAIAgent(this A2AClient client, string? id = null, string? name = null, string? description = null, ILoggerFactory? loggerFactory = null) =>
new A2AAgent(client, id, name, description, loggerFactory);
}
@@ -60,18 +60,6 @@ public abstract class AIAgent
/// </remarks>
public virtual string? Name { get; }
/// <summary>
/// Gets a display-friendly name for the agent.
/// </summary>
/// <value>
/// The agent's <see cref="Name"/> if available, otherwise the <see cref="Id"/>.
/// </value>
/// <remarks>
/// This property provides a guaranteed non-null string suitable for display in user interfaces,
/// logs, or other contexts where a readable identifier is needed.
/// </remarks>
public virtual string DisplayName => this.Name ?? this.Id;
/// <summary>
/// Gets a description of the agent's purpose, capabilities, or behavior.
/// </summary>
@@ -230,6 +218,35 @@ public abstract class AIAgent
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreAsync"/> to perform the actual agent invocation. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
/// </para>
/// <para>
/// The messages are processed in the order provided and become part of the conversation history.
/// The agent's response will also be added to <paramref name="thread"/> if one is provided.
/// </para>
/// </remarks>
public Task<AgentRunResponse> RunAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunCoreAsync(messages, thread, options, cancellationToken);
/// <summary>
/// Core implementation of the agent invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response messages generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains an <see cref="AgentRunResponse"/> with the agent's output.</returns>
/// <remarks>
/// <para>
/// This is the primary invocation method that implementations must override. It handles collections of messages,
/// allowing for complex conversational scenarios including multi-turn interactions, function calls, and
/// context-rich conversations.
@@ -239,7 +256,7 @@ public abstract class AIAgent
/// The agent's response will also be added to <paramref name="thread"/> if one is provided.
/// </para>
/// </remarks>
public abstract Task<AgentRunResponse> RunAsync(
protected abstract Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -324,6 +341,34 @@ public abstract class AIAgent
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This method delegates to <see cref="RunCoreStreamingAsync"/> to perform the actual streaming invocation. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
/// <para>
/// Each <see cref="AgentRunResponseUpdate"/> represents a portion of the complete response, allowing consumers
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// </remarks>
public IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
this.RunCoreStreamingAsync(messages, thread, options, cancellationToken);
/// <summary>
/// Core implementation of the agent streaming invocation logic with a collection of chat messages.
/// </summary>
/// <param name="messages">The collection of messages to send to the agent for processing.</param>
/// <param name="thread">
/// The conversation thread to use for this invocation. If <see langword="null"/>, a new thread will be created.
/// The thread will be updated with the input messages and any response updates generated during invocation.
/// </param>
/// <param name="options">Optional configuration parameters for controlling the agent's invocation behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>An asynchronous enumerable of <see cref="AgentRunResponseUpdate"/> instances representing the streaming response.</returns>
/// <remarks>
/// <para>
/// This is the primary streaming invocation method that implementations must override. It provides real-time
/// updates as the agent processes the input and generates its response, enabling more responsive user experiences.
/// </para>
@@ -332,7 +377,7 @@ public abstract class AIAgent
/// to display partial results, implement progressive loading, or provide immediate feedback to users.
/// </para>
/// </remarks>
public abstract IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected abstract IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -81,7 +81,7 @@ public abstract class DelegatingAIAgent : AIAgent
=> this.InnerAgent.DeserializeThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc />
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -89,7 +89,7 @@ public abstract class DelegatingAIAgent : AIAgent
=> this.InnerAgent.RunAsync(messages, thread, options, cancellationToken);
/// <inheritdoc />
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -58,7 +58,7 @@ public class CopilotStudioAgent : AIAgent
=> new CopilotStudioAgentThread(serializedThread, jsonSerializerOptions);
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -96,7 +96,7 @@ public class CopilotStudioAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -231,7 +231,7 @@ internal static class EntitiesApiExtensions
return new EntityInfo(
Id: entityId,
Type: "agent",
Name: agent.DisplayName,
Name: agent.Name ?? agent.Id,
Description: agent.Description,
Framework: "agent_framework",
Tools: tools,
@@ -63,7 +63,7 @@ public sealed class DurableAIAgent : AIAgent
/// <exception cref="AgentNotRegisteredException">Thrown when the agent has not been registered.</exception>
/// <exception cref="ArgumentException">Thrown when the provided thread is not valid for a durable agent.</exception>
/// <exception cref="NotSupportedException">Thrown when cancellation is requested (cancellation is not supported for durable agents).</exception>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -128,7 +128,7 @@ public sealed class DurableAIAgent : AIAgent
/// <param name="options">Optional run options.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A streaming response enumerable.</returns>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -23,7 +23,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
return new DurableAgentThread(AgentSessionId.WithRandomKey(this.Name!));
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -70,7 +70,7 @@ internal class DurableAIAgentProxy(string name, IDurableAgentClient agentClient)
return await agentRunHandle.ReadAgentResponseAsync(cancellationToken);
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -21,13 +21,13 @@ internal sealed class EntityAgentWrapper(
// The ID of the agent is always the entity ID.
protected override string? IdCore => this._entityContext.Id.ToString();
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default)
{
AgentRunResponse response = await base.RunAsync(
AgentRunResponse response = await base.RunCoreAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
@@ -37,13 +37,13 @@ internal sealed class EntityAgentWrapper(
return response;
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (AgentRunResponseUpdate update in base.RunStreamingAsync(
await foreach (AgentRunResponseUpdate update in base.RunCoreStreamingAsync(
messages,
thread,
this.GetAgentEntityRunOptions(options),
@@ -32,7 +32,7 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
}
HttpRequestData? httpRequestData = null;
TaskEntityDispatcher? dispatcher = null;
string? encodedEntityRequest = null;
DurableTaskClient? durableTaskClient = null;
ToolInvocationContext? mcpToolInvocationContext = null;
@@ -43,8 +43,8 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
case HttpRequestData request:
httpRequestData = request;
break;
case TaskEntityDispatcher entityDispatcher:
dispatcher = entityDispatcher;
case string entityRequest:
encodedEntityRequest = entityRequest;
break;
case DurableTaskClient client:
durableTaskClient = client;
@@ -78,14 +78,14 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RunAgentEntityFunctionEntryPoint)
{
if (dispatcher is null)
if (encodedEntityRequest is null)
{
throw new InvalidOperationException($"Task entity dispatcher binding is missing for the invocation {context.InvocationId}.");
}
await BuiltInFunctions.InvokeAgentAsync(
dispatcher,
context.GetInvocationResult().Value = await BuiltInFunctions.InvokeAgentAsync(
durableTaskClient,
encodedEntityRequest,
context);
return;
}
@@ -7,6 +7,7 @@ using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker.Grpc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
@@ -22,14 +23,14 @@ internal static class BuiltInFunctions
internal static readonly string RunAgentMcpToolFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunMcpToolAsync)}";
// Exposed as an entity trigger via AgentFunctionsProvider
public static async Task InvokeAgentAsync(
[EntityTrigger] TaskEntityDispatcher dispatcher,
public static Task<string> InvokeAgentAsync(
[DurableClient] DurableTaskClient client,
string encodedEntityRequest,
FunctionContext functionContext)
{
// This should never be null except if the function trigger is misconfigured.
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(encodedEntityRequest);
ArgumentNullException.ThrowIfNull(functionContext);
// Create a combined service provider that includes both the existing services
@@ -38,7 +39,8 @@ internal static class BuiltInFunctions
// This method is the entry point for the agent entity.
// It will be invoked by the Azure Functions runtime when the entity is called.
await dispatcher.DispatchAsync(new AgentEntity(combinedServiceProvider, functionContext.CancellationToken));
AgentEntity entity = new(combinedServiceProvider, functionContext.CancellationToken);
return GrpcEntityRunner.LoadAndRunAsync(encodedEntityRequest, entity, combinedServiceProvider);
}
public static async Task<HttpResponseData> RunAgentHttpAsync(
@@ -1,5 +1,9 @@
# Release History
## <version>
- Addressed incompatibility issue with `Microsoft.Azure.Functions.Worker.Extensions.DurableTask` >= 1.11.0 ([#2759](https://github.com/microsoft/agent-framework/pull/2759))
## v1.0.0-preview.251125.1
- Added support for .NET 10 ([#2128](https://github.com/microsoft/agent-framework/pull/2128))
@@ -73,7 +73,7 @@ internal sealed class DurableAgentFunctionMetadataTransformer : IFunctionMetadat
Language = "dotnet-isolated",
RawBindings =
[
"""{"name":"dispatcher","type":"entityTrigger","direction":"In"}""",
"""{"name":"encodedEntityRequest","type":"entityTrigger","direction":"In"}""",
"""{"name":"client","type":"durableClient","direction":"In"}"""
],
EntryPoint = BuiltInFunctions.RunAgentEntityFunctionEntryPoint,
@@ -61,7 +61,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
path ??= $"/{agent.Name}/v1/chat/completions";
var group = endpoints.MapGroup(path);
var endpointAgentName = agent.DisplayName;
var endpointAgentName = agent.Name ?? agent.Id;
group.MapPost("/", async ([FromBody] CreateChatCompletion request, CancellationToken cancellationToken)
=> await AIAgentChatCompletionsProcessor.CreateChatCompletionAsync(agent, request, cancellationToken).ConfigureAwait(false))
@@ -76,7 +76,7 @@ public static partial class MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExt
var handlers = new ResponsesHttpHandler(responsesService);
var group = endpoints.MapGroup(responsesPath);
var endpointAgentName = agent.DisplayName;
var endpointAgentName = agent.Name ?? agent.Id;
// Create response endpoint
group.MapPost("/", handlers.CreateResponseAsync)
@@ -42,13 +42,13 @@ internal class PurviewAgent : AIAgent, IDisposable
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken);
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var response = await this._purviewWrapper.ProcessAgentContentAsync(messages, thread, options, this._innerAgent, cancellationToken).ConfigureAwait(false);
foreach (var update in response.ToAgentRunResponseUpdates())
@@ -125,14 +125,14 @@ public sealed class HandoffsWorkflowBuilder
{
Throw.ArgumentException(
nameof(to),
$"The provided target agent '{to.DisplayName}' has no description, name, or instructions, and no handoff description has been provided. " +
$"The provided target agent '{to.Name ?? to.Id}' has no description, name, or instructions, and no handoff description has been provided. " +
"At least one of these is required to register a handoff so that the appropriate target agent can be chosen.");
}
}
if (!handoffs.Add(new(to, handoffReason)))
{
Throw.InvalidOperationException($"A handoff from agent '{from.DisplayName}' to agent '{to.DisplayName}' has already been registered.");
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
}
return this;
@@ -20,7 +20,7 @@ internal sealed class AgentRunStreamingExecutor(AIAgent agent, bool includeInput
protected override async ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
{
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.DisplayName);
List<ChatMessage>? roleChanged = messages.ChangeAssistantToUserForOtherParticipants(agent.Name ?? agent.Id);
List<AgentRunResponseUpdate> updates = [];
await foreach (var update in agent.RunStreamingAsync(messages, cancellationToken: cancellationToken).ConfigureAwait(false))
@@ -67,7 +67,7 @@ internal sealed class HandoffAgentExecutor(
List<AgentRunResponseUpdate> updates = [];
List<ChatMessage> allMessages = handoffState.Messages;
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.DisplayName);
List<ChatMessage>? roleChanges = allMessages.ChangeAssistantToUserForOtherParticipants(this._agent.Name ?? this._agent.Id);
await foreach (var update in this._agent.RunStreamingAsync(allMessages,
options: this._agentOptions,
@@ -85,7 +85,7 @@ internal sealed class HandoffAgentExecutor(
new AgentRunResponseUpdate
{
AgentId = this._agent.Id,
AuthorName = this._agent.DisplayName,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(fcc.CallId, "Transferred.")],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
@@ -79,8 +79,8 @@ internal sealed class WorkflowHostAgent : AIAgent
return workflowThread;
}
public override async
Task<AgentRunResponse> RunAsync(
protected override async
Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -101,8 +101,8 @@ internal sealed class WorkflowHostAgent : AIAgent
return merger.ComputeMerged(workflowThread.LastResponseId!, this.Id, this.Name);
}
public override async
IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async
IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -17,18 +17,18 @@ namespace Microsoft.Agents.AI;
/// </remarks>
internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
{
/// <summary>The delegate to use as the implementation of <see cref="RunAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of <see cref="RunCoreAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, Task<AgentRunResponse>>? _runFunc;
/// <summary>The delegate to use as the implementation of <see cref="RunStreamingAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of <see cref="RunCoreStreamingAsync"/>.</summary>
/// <remarks>
/// When non-<see langword="null"/>, this delegate is used as the implementation of <see cref="RunStreamingAsync"/> and
/// When non-<see langword="null"/>, this delegate is used as the implementation of <see cref="RunCoreStreamingAsync"/> and
/// will be invoked with the same arguments as the method itself.
/// When <see langword="null"/>, <see cref="RunStreamingAsync"/> will delegate directly to the inner agent.
/// When <see langword="null"/>, <see cref="RunCoreStreamingAsync"/> will delegate directly to the inner agent.
/// </remarks>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, AIAgent, CancellationToken, IAsyncEnumerable<AgentRunResponseUpdate>>? _runStreamingFunc;
/// <summary>The delegate to use as the implementation of both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.</summary>
/// <summary>The delegate to use as the implementation of both <see cref="RunCoreAsync"/> and <see cref="RunCoreStreamingAsync"/>.</summary>
private readonly Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, Func<IEnumerable<ChatMessage>, AgentThread?, AgentRunOptions?, CancellationToken, Task>, CancellationToken, Task>? _sharedFunc;
/// <summary>
@@ -36,7 +36,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="sharedFunc">
/// A delegate that provides the implementation for both <see cref="RunAsync"/> and <see cref="RunStreamingAsync"/>.
/// A delegate that provides the implementation for both <see cref="RunCoreAsync"/> and <see cref="RunCoreStreamingAsync"/>.
/// In addition to the arguments for the operation, it's provided with a delegate to the inner agent that should be
/// used to perform the operation on the inner agent. It will handle both the non-streaming and streaming cases.
/// </param>
@@ -61,13 +61,13 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
/// </summary>
/// <param name="innerAgent">The inner agent.</param>
/// <param name="runFunc">
/// A delegate that provides the implementation for <see cref="RunAsync"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="RunAsync"/>
/// A delegate that provides the implementation for <see cref="RunCoreAsync"/>. When <see langword="null"/>,
/// <paramref name="runStreamingFunc"/> must be non-null, and the implementation of <see cref="RunCoreAsync"/>
/// will use <paramref name="runStreamingFunc"/> for the implementation.
/// </param>
/// <param name="runStreamingFunc">
/// A delegate that provides the implementation for <see cref="RunStreamingAsync"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="RunStreamingAsync"/>
/// A delegate that provides the implementation for <see cref="RunCoreStreamingAsync"/>. When <see langword="null"/>,
/// <paramref name="runFunc"/> must be non-null, and the implementation of <see cref="RunCoreStreamingAsync"/>
/// will use <paramref name="runFunc"/> for the implementation.
/// </param>
/// <exception cref="ArgumentNullException"><paramref name="innerAgent"/> is <see langword="null"/>.</exception>
@@ -85,7 +85,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -132,7 +132,7 @@ internal sealed class AnonymousDelegatingAIAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -149,7 +149,7 @@ public sealed partial class ChatClientAgent : AIAgent
internal ChatOptions? ChatOptions => this._agentOptions?.ChatOptions;
/// <inheritdoc/>
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -193,7 +193,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -21,10 +21,10 @@ internal sealed class FunctionInvocationDelegatingAgent : DelegatingAIAgent
this._delegateFunc = delegateFunc;
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> this.InnerAgent.RunStreamingAsync(messages, thread, this.AgentRunOptionsWithFunctionMiddleware(options), cancellationToken);
// Decorate options to add the middleware function
@@ -55,7 +55,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
@@ -72,7 +72,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
try
{
AgentRunResponse response = await base.RunAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
AgentRunResponse response = await base.RunCoreAsync(messages, thread, options, cancellationToken).ConfigureAwait(false);
if (this._logger.IsEnabled(LogLevel.Debug))
{
@@ -101,7 +101,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (this._logger.IsEnabled(LogLevel.Debug))
@@ -119,7 +119,7 @@ public sealed partial class LoggingAgent : DelegatingAIAgent
IAsyncEnumerator<AgentRunResponseUpdate> e;
try
{
e = base.RunStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
e = base.RunCoreStreamingAsync(messages, thread, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (OperationCanceledException)
{
@@ -78,7 +78,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <inheritdoc/>
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
@@ -89,7 +89,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <inheritdoc/>
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatOptions co = new ForwardedOptions(options, thread, Activity.Current);
@@ -114,7 +114,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// Override information set by OpenTelemetryChatClient to make it specific to invoke_agent.
activity.DisplayName = $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.DisplayName}";
activity.DisplayName = string.IsNullOrWhiteSpace(this.Name)
? $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Id}"
: $"{OpenTelemetryConsts.GenAI.InvokeAgent} {this.Name}({this.Id})";
activity.SetTag(OpenTelemetryConsts.GenAI.Operation.Name, OpenTelemetryConsts.GenAI.InvokeAgent);
if (!string.IsNullOrWhiteSpace(this._providerName))
@@ -42,16 +42,14 @@ public sealed class A2AAgentTests : IDisposable
const string TestId = "test-id";
const string TestName = "test-name";
const string TestDescription = "test-description";
const string TestDisplayName = "test-display-name";
// Act
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription, TestDisplayName);
var agent = new A2AAgent(this._a2aClient, TestId, TestName, TestDescription);
// Assert
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
Assert.Equal(TestDisplayName, agent.DisplayName);
}
[Fact]
@@ -70,7 +68,6 @@ public sealed class A2AAgentTests : IDisposable
Assert.NotEmpty(agent.Id);
Assert.Null(agent.Name);
Assert.Null(agent.Description);
Assert.Equal(agent.Id, agent.DisplayName);
}
[Fact]
@@ -19,10 +19,9 @@ public sealed class A2AClientExtensionsTests
const string TestId = "test-agent-id";
const string TestName = "Test Agent";
const string TestDescription = "This is a test agent description";
const string TestDisplayName = "Test Display Name";
// Act
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription, TestDisplayName);
var agent = a2aClient.GetAIAgent(TestId, TestName, TestDescription);
// Assert
Assert.NotNull(agent);
@@ -30,6 +29,5 @@ public sealed class A2AClientExtensionsTests
Assert.Equal(TestId, agent.Id);
Assert.Equal(TestName, agent.Name);
Assert.Equal(TestDescription, agent.Description);
Assert.Equal(TestDisplayName, agent.DisplayName);
}
}
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Abstractions.UnitTests;
@@ -33,18 +34,20 @@ public class AIAgentTests
this._agentMock = new Mock<AIAgent> { CallBase = true };
this._agentMock
.Setup(x => x.RunAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._invokeResponse);
this._agentMock
.Setup(x => x.RunStreamingAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
this._agentThreadMock.Object,
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._invokeStreamingResponses));
}
@@ -64,13 +67,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -90,13 +94,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == Message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -116,13 +121,14 @@ public class AIAgentTests
Assert.Equal(this._invokeResponse, response);
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -144,13 +150,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 0),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => !messages.Any()),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -173,13 +180,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First().Text == Message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First().Text == Message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
/// <summary>
@@ -202,13 +210,14 @@ public class AIAgentTests
}
// Verify that the mocked method was called with the expected parameters
this._agentMock.Verify(
x => x.RunStreamingAsync(
It.Is<IReadOnlyCollection<ChatMessage>>(messages => messages.Count == 1 && messages.First() == message),
this._agentThreadMock.Object,
options,
cancellationToken),
Times.Once);
this._agentMock
.Protected()
.Verify<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(messages => messages.Count() == 1 && messages.First() == message),
ItExpr.Is<AgentThread?>(t => t == this._agentThreadMock.Object),
ItExpr.Is<AgentRunOptions?>(o => o == options),
ItExpr.Is<CancellationToken>(ct => ct == cancellationToken));
}
[Fact]
@@ -375,14 +384,14 @@ public class AIAgentTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -38,19 +38,21 @@ public class DelegatingAIAgentTests
this._innerAgentMock.Setup(x => x.GetNewThread()).Returns(this._testThread);
this._innerAgentMock
.Setup(x => x.RunAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._testResponse);
this._innerAgentMock
.Setup(x => x.RunStreamingAsync(
It.IsAny<IReadOnlyCollection<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
this._delegatingAgent = new TestDelegatingAIAgent(this._innerAgentMock.Object);
@@ -159,7 +161,12 @@ public class DelegatingAIAgentTests
var innerAgentMock = new Mock<AIAgent>();
innerAgentMock
.Setup(x => x.RunAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
ItExpr.Is<AgentThread?>(t => t == expectedThread),
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
.Returns(expectedResult.Task);
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
@@ -193,7 +200,12 @@ public class DelegatingAIAgentTests
var innerAgentMock = new Mock<AIAgent>();
innerAgentMock
.Setup(x => x.RunStreamingAsync(expectedMessages, expectedThread, expectedOptions, expectedCancellationToken))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == expectedMessages),
ItExpr.Is<AgentThread?>(t => t == expectedThread),
ItExpr.Is<AgentRunOptions?>(o => o == expectedOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken))
.Returns(ToAsyncEnumerableAsync(expectedResults));
var delegatingAgent = new TestDelegatingAIAgent(innerAgentMock.Object);
@@ -76,12 +76,12 @@ public sealed class AggregatorPromptAgentFactoryTests
throw new NotImplementedException();
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
@@ -290,7 +290,7 @@ internal sealed class FakeChatClientAgent : AIAgent
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -305,7 +305,7 @@ internal sealed class FakeChatClientAgent : AIAgent
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -358,7 +358,7 @@ internal sealed class FakeMultiMessageAgent : AIAgent
return new FakeInMemoryAgentThread(serializedThread, jsonSerializerOptions);
}
public override async Task<AgentRunResponse> RunAsync(
protected override async Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -373,7 +373,7 @@ internal sealed class FakeMultiMessageAgent : AIAgent
return updates.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -303,12 +303,12 @@ internal sealed class FakeForwardedPropsAgent : AIAgent
public JsonElement ReceivedForwardedProperties { get; private set; }
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -342,12 +342,12 @@ internal sealed class FakeStateAgent : AIAgent
{
public override string? Description => "Agent for state testing";
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
return this.RunStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
return this.RunCoreStreamingAsync(messages, thread, options, cancellationToken).ToAgentRunResponseAsync(cancellationToken);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -430,12 +430,12 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -519,12 +519,12 @@ public sealed class AGUIEndpointRouteBuilderExtensionsTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null) =>
new TestInMemoryAgentThread(serializedThread, jsonSerializerOptions);
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -19,6 +19,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
private const string AzureFunctionsPort = "7071";
private const string AzuritePort = "10000";
private const string DtsPort = "8080";
private const string RedisPort = "6379";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
private static readonly HttpClient s_sharedHttpClient = new();
@@ -392,6 +393,136 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
});
}
[Fact]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "08_ReliableStreaming");
await this.RunSampleTestAsync(samplePath, async (logs) =>
{
Uri createUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/create");
this._outputHelper.WriteLine($"Starting reliable streaming agent via POST request to {createUri}...");
// Test the agent endpoint with a simple prompt
const string RequestBody = "Plan a 3-day trip to Seattle. Include daily activities.";
using HttpContent content = new StringContent(RequestBody, Encoding.UTF8, "text/plain");
using HttpRequestMessage request = new(HttpMethod.Post, createUri)
{
Content = content
};
request.Headers.Add("Accept", "text/plain");
using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead);
// The response should be successful
Assert.True(response.IsSuccessStatusCode, $"Agent request failed with status: {response.StatusCode}");
Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
// The response headers should include the conversation ID
string? conversationId = response.Headers.GetValues("x-conversation-id")?.FirstOrDefault();
Assert.NotNull(conversationId);
Assert.NotEmpty(conversationId);
this._outputHelper.WriteLine($"Agent conversation ID: {conversationId}");
// Read the streamed response
using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new(responseStream);
StringBuilder responseText = new();
char[] buffer = new char[1024];
int bytesRead;
// Read for a reasonable amount of time to get some content
using CancellationTokenSource readTimeout = new(TimeSpan.FromSeconds(30));
try
{
while (!readTimeout.Token.IsCancellationRequested)
{
bytesRead = await reader.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
{
// Check if we've received enough content
if (responseText.Length > 50)
{
break;
}
await Task.Delay(100, readTimeout.Token);
continue;
}
responseText.Append(buffer, 0, bytesRead);
if (responseText.Length > 200)
{
// We've received enough content to validate
break;
}
}
}
catch (OperationCanceledException)
{
// Timeout is acceptable if we got some content
}
string responseContent = responseText.ToString();
Assert.True(responseContent.Length > 0, "Expected to receive some streamed content");
this._outputHelper.WriteLine($"Received {responseContent.Length} characters of streamed content");
// Test resumption by calling the stream endpoint
Uri streamUri = new($"http://localhost:{AzureFunctionsPort}/api/agent/stream/{conversationId}");
this._outputHelper.WriteLine($"Testing stream resumption via GET request to {streamUri}...");
using HttpRequestMessage streamRequest = new(HttpMethod.Get, streamUri);
streamRequest.Headers.Add("Accept", "text/plain");
using HttpResponseMessage streamResponse = await s_sharedHttpClient.SendAsync(
streamRequest,
HttpCompletionOption.ResponseHeadersRead);
Assert.True(streamResponse.IsSuccessStatusCode, $"Stream request failed with status: {streamResponse.StatusCode}");
Assert.Equal("text/plain", streamResponse.Content.Headers.ContentType?.MediaType);
// Verify the conversation ID header is present
string? resumedConversationId = streamResponse.Headers.GetValues("x-conversation-id")?.FirstOrDefault();
Assert.Equal(conversationId, resumedConversationId);
// Read some content from the resumed stream
using Stream resumedStream = await streamResponse.Content.ReadAsStreamAsync();
using StreamReader resumedReader = new(resumedStream);
StringBuilder resumedText = new();
using CancellationTokenSource resumedReadTimeout = new(TimeSpan.FromSeconds(10));
try
{
while (!resumedReadTimeout.Token.IsCancellationRequested)
{
bytesRead = await resumedReader.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0)
{
if (resumedText.Length > 50)
{
break;
}
await Task.Delay(100, resumedReadTimeout.Token);
continue;
}
resumedText.Append(buffer, 0, bytesRead);
if (resumedText.Length > 100)
{
break;
}
}
}
catch (OperationCanceledException)
{
// Timeout is acceptable if we got some content
}
string resumedContent = resumedText.ToString();
Assert.True(resumedContent.Length > 0, "Expected to receive some content from resumed stream");
this._outputHelper.WriteLine($"Received {resumedContent.Length} characters from resumed stream");
});
}
private async Task<string> InvokeMcpToolAsync(McpClient mcpClient, string toolName, string query)
{
this._outputHelper.WriteLine($"Invoking MCP tool '{toolName}'...");
@@ -482,6 +613,21 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
message: "DTS emulator is running",
timeout: TimeSpan.FromSeconds(30));
}
// Start Redis if it's not already running
if (!await this.IsRedisRunningAsync())
{
await this.StartDockerContainerAsync(
containerName: "redis",
image: "redis:latest",
ports: ["-p", "6379:6379"]);
// Wait for Redis
await this.WaitForConditionAsync(
condition: this.IsRedisRunningAsync,
message: "Redis is running",
timeout: TimeSpan.FromSeconds(30));
}
}
private async Task<bool> IsAzuriteRunningAsync()
@@ -562,6 +708,49 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
}
}
private async Task<bool> IsRedisRunningAsync()
{
this._outputHelper.WriteLine($"Checking if Redis is running at localhost:{RedisPort}...");
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
ProcessStartInfo startInfo = new()
{
FileName = "docker",
Arguments = "exec redis redis-cli ping",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using Process process = new() { StartInfo = startInfo };
if (!process.Start())
{
this._outputHelper.WriteLine("Failed to start docker exec command");
return false;
}
string output = await process.StandardOutput.ReadToEndAsync(timeoutCts.Token);
await process.WaitForExitAsync(timeoutCts.Token);
if (process.ExitCode == 0 && output.Contains("PONG", StringComparison.OrdinalIgnoreCase))
{
this._outputHelper.WriteLine("Redis is running");
return true;
}
this._outputHelper.WriteLine($"Redis is not running. Exit code: {process.ExitCode}, Output: {output}");
return false;
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"Redis is not running: {ex.Message}");
return false;
}
}
private async Task StartDockerContainerAsync(string containerName, string image, string[] ports)
{
// Stop existing container if it exists
@@ -646,6 +835,7 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi
startInfo.EnvironmentVariables["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"] =
$"Endpoint=http://localhost:{DtsPort};TaskHub=default;Authentication=None";
startInfo.EnvironmentVariables["AzureWebJobsStorage"] = "UseDevelopmentStorage=true";
startInfo.EnvironmentVariables["REDIS_CONNECTION_STRING"] = $"localhost:{RedisPort}";
Process process = new() { StartInfo = startInfo };
@@ -17,13 +17,13 @@ internal sealed class TestAgent(string name, string description) : AIAgent
JsonElement serializedThread,
JsonSerializerOptions? jsonSerializerOptions = null) => new DummyAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
CancellationToken cancellationToken = default) => Task.FromResult(new AgentRunResponse([.. messages]));
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -6,6 +6,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.Protected;
using ChatMessage = Microsoft.Extensions.AI.ChatMessage;
using ChatRole = Microsoft.Extensions.AI.ChatRole;
using OpenAIChatMessage = OpenAI.Chat.ChatMessage;
@@ -76,22 +77,28 @@ public sealed class AIAgentWithOpenAIExtensionsTests
var responseMessage = new ChatMessage(ChatRole.Assistant, [new TextContent(ResponseText)]);
mockAgent
.Setup(a => a.RunAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<AgentThread?>(), It.IsAny<AgentRunOptions?>(), It.IsAny<CancellationToken>()))
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new AgentRunResponse([responseMessage]));
// Act
var result = await mockAgent.Object.RunAsync(openAiMessages, mockThread.Object, options, cancellationToken);
// Assert
mockAgent.Verify(
a => a.RunAsync(
It.Is<IEnumerable<ChatMessage>>(msgs =>
mockAgent.Protected()
.Verify("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(msgs =>
msgs.ToList().Count == 1 &&
msgs.ToList()[0].Text == TestMessageText),
mockThread.Object,
options,
cancellationToken),
Times.Once);
cancellationToken
);
Assert.NotNull(result);
Assert.NotEmpty(result.Content);
@@ -160,7 +167,12 @@ public sealed class AIAgentWithOpenAIExtensionsTests
};
mockAgent
.Setup(a => a.RunStreamingAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<AgentThread?>(), It.IsAny<AgentRunOptions?>(), It.IsAny<CancellationToken>()))
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(responseUpdates));
// Act
@@ -172,15 +184,16 @@ public sealed class AIAgentWithOpenAIExtensionsTests
}
// Assert
mockAgent.Verify(
a => a.RunStreamingAsync(
It.Is<IEnumerable<ChatMessage>>(msgs =>
mockAgent.Protected()
.Verify("RunCoreStreamingAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(msgs =>
msgs.ToList().Count == 1 &&
msgs.ToList()[0].Text == TestMessageText),
mockThread.Object,
options,
cancellationToken),
Times.Once);
cancellationToken
);
Assert.True(updateCount > 0, "Expected at least one streaming update");
}
@@ -8,6 +8,7 @@ using Microsoft.Agents.AI.Purview.Models.Common;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.Purview.UnitTests;
@@ -277,11 +278,13 @@ public sealed class PurviewWrapperTests : IDisposable
Assert.Single(result.Messages);
Assert.Equal(ChatRole.System, result.Messages[0].Role);
Assert.Equal("Prompt blocked by policy", result.Messages[0].Text);
mockAgent.Verify(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()), Times.Never);
mockAgent.Protected().Verify("RunCoreAsync",
Times.Never(),
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>());
}
[Fact]
@@ -295,11 +298,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Sensitive response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
@@ -333,11 +337,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Safe response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
@@ -375,11 +380,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response from inner agent"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
this._mockProcessor.SetupSequence(x => x.ProcessMessagesAsync(
@@ -441,11 +447,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
this._mockProcessor.Setup(x => x.ProcessMessagesAsync(
@@ -482,11 +489,12 @@ public sealed class PurviewWrapperTests : IDisposable
var expectedResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
var mockAgent = new Mock<AIAgent>();
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(expectedResponse);
string? capturedThreadId = null;
@@ -521,11 +529,12 @@ public sealed class PurviewWrapperTests : IDisposable
var mockAgent = new Mock<AIAgent>();
var innerResponse = new AgentRunResponse(new ChatMessage(ChatRole.Assistant, "Response"));
mockAgent.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread>(),
It.IsAny<AgentRunOptions>(),
It.IsAny<CancellationToken>()))
mockAgent.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread>(),
ItExpr.IsAny<AgentRunOptions>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(innerResponse);
var callCount = 0;
@@ -337,7 +337,7 @@ public class AgentExtensionsTests
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -355,7 +355,7 @@ public class AgentExtensionsTests
return Task.FromResult(this._responseToReturn!);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
@@ -35,18 +36,22 @@ public class AnonymousDelegatingAIAgentTests
new AgentRunResponseUpdate(ChatRole.Assistant, "Response 2")
];
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(this._testResponse);
this._innerAgentMock.Setup(x => x.RunStreamingAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<IAsyncEnumerable<AgentRunResponseUpdate>>("RunCoreStreamingAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(ToAsyncEnumerableAsync(this._testStreamingResponses));
}
@@ -184,11 +189,14 @@ public class AnonymousDelegatingAIAgentTests
Assert.Same(this._testOptions, capturedOptions);
Assert.Equal(expectedCancellationToken, capturedCancellationToken);
this._innerAgentMock.Verify(x => x.RunAsync(
this._testMessages,
this._testThread,
this._testOptions,
expectedCancellationToken), Times.Once);
this._innerAgentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Once(),
ItExpr.Is<IEnumerable<ChatMessage>>(m => m == this._testMessages),
ItExpr.Is<AgentThread?>(t => t == this._testThread),
ItExpr.Is<AgentRunOptions?>(o => o == this._testOptions),
ItExpr.Is<CancellationToken>(ct => ct == expectedCancellationToken));
}
/// <summary>
@@ -458,11 +466,13 @@ public class AnonymousDelegatingAIAgentTests
capturedValue = asyncLocal.Value;
});
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>())
.Returns(() =>
{
// Verify AsyncLocal value is available in inner agent call
@@ -926,11 +936,13 @@ public class AnonymousDelegatingAIAgentTests
var capturedTokens = new List<CancellationToken>();
// Setup mock to throw OperationCanceledException when cancelled token is used
this._innerAgentMock.Setup(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.Is<CancellationToken>(ct => ct.IsCancellationRequested)))
this._innerAgentMock
.Protected()
.Setup<Task<AgentRunResponse>>("RunCoreAsync",
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.Is<CancellationToken>(ct => ct.IsCancellationRequested))
.ThrowsAsync(new OperationCanceledException());
var agent = new AIAgentBuilder(this._innerAgentMock.Object)
@@ -993,11 +1005,14 @@ public class AnonymousDelegatingAIAgentTests
Assert.Equal(expectedOrder, executionOrder);
// Verify inner agent was never called
this._innerAgentMock.Verify(x => x.RunAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<AgentThread?>(),
It.IsAny<AgentRunOptions?>(),
It.IsAny<CancellationToken>()), Times.Never);
this._innerAgentMock
.Protected()
.Verify<Task<AgentRunResponse>>("RunCoreAsync",
Times.Never(),
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
ItExpr.IsAny<AgentThread?>(),
ItExpr.IsAny<AgentRunOptions?>(),
ItExpr.IsAny<CancellationToken>());
}
#endregion
@@ -42,7 +42,6 @@ public class LoggingAgentTests
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("This is a test agent.", agent.Description);
Assert.Equal(innerAgent.Id, agent.Id);
Assert.Equal(innerAgent.DisplayName, agent.DisplayName);
}
[Fact]
@@ -45,7 +45,6 @@ public class OpenTelemetryAgentTests
Assert.Equal("TestAgent", agent.Name);
Assert.Equal("This is a test agent.", agent.Description);
Assert.Equal(innerAgent.Id, agent.Id);
Assert.Equal(innerAgent.DisplayName, agent.DisplayName);
}
[Fact]
@@ -170,7 +169,7 @@ public class OpenTelemetryAgentTests
Assert.Equal("localhost", activity.GetTagItem("server.address"));
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
Assert.Equal("invoke_agent TestAgent", activity.DisplayName);
Assert.Equal($"invoke_agent {agent.Name}({agent.Id})", activity.DisplayName);
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
@@ -431,7 +430,15 @@ public class OpenTelemetryAgentTests
Assert.Equal("localhost", activity.GetTagItem("server.address"));
Assert.Equal(12345, (int)activity.GetTagItem("server.port")!);
Assert.Equal($"invoke_agent {innerAgent.DisplayName}", activity.DisplayName);
if (string.IsNullOrWhiteSpace(innerAgent.Name))
{
Assert.Equal($"invoke_agent {innerAgent.Id}", activity.DisplayName);
}
else
{
Assert.Equal($"invoke_agent {innerAgent.Name}({innerAgent.Id})", activity.DisplayName);
}
Assert.Equal("invoke_agent", activity.GetTagItem("gen_ai.operation.name"));
Assert.Equal("TestAgentProviderFromAIAgentMetadata", activity.GetTagItem("gen_ai.provider.name"));
Assert.Equal(innerAgent.Name, activity.GetTagItem("gen_ai.agent.name"));
@@ -30,10 +30,10 @@ internal sealed class TestAIAgent : AIAgent
public override AgentThread GetNewThread() =>
this.GetNewThreadFunc();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunAsyncFunc(messages, thread, options, cancellationToken);
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
this.RunStreamingAsyncFunc(messages, thread, options, cancellationToken);
public override object? GetService(Type serviceType, object? serviceKey = null) =>
@@ -141,11 +141,11 @@ public class AgentWorkflowBuilderTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new DoubleEchoAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.Yield();
@@ -409,7 +409,7 @@ public class AgentWorkflowBuilderTests
private sealed class DoubleEchoAgentWithBarrier(string name, StrongBox<TaskCompletionSource<bool>> barrier, StrongBox<int> remaining) : DoubleEchoAgent(name)
{
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Decrement(ref remaining.Value) == 0)
@@ -419,7 +419,7 @@ public class AgentWorkflowBuilderTests
await barrier.Value!.Task.ConfigureAwait(false);
await foreach (var update in base.RunStreamingAsync(messages, thread, options, cancellationToken))
await foreach (var update in base.RunCoreStreamingAsync(messages, thread, options, cancellationToken))
{
await Task.Yield();
yield return update;
@@ -149,7 +149,7 @@ public class InProcessExecutionTests
public override AgentThread DeserializeThread(System.Text.Json.JsonElement serializedThread,
System.Text.Json.JsonSerializerOptions? jsonSerializerOptions = null) => new SimpleTestAgentThread();
public override Task<AgentRunResponse> RunAsync(
protected override Task<AgentRunResponse> RunCoreAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -160,7 +160,7 @@ public class InProcessExecutionTests
return Task.FromResult(new AgentRunResponse(responseMessage));
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(
IEnumerable<ChatMessage> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -30,10 +30,10 @@ public class RepresentationTests
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> throw new NotImplementedException();
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
}
@@ -66,17 +66,17 @@ internal sealed class HelloAgent(string id = nameof(HelloAgent)) : AIAgent
public override AgentThread DeserializeThread(JsonElement serializedThread, JsonSerializerOptions? jsonSerializerOptions = null)
=> new HelloAgentThread();
public override async Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override async Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
IEnumerable<AgentRunResponseUpdate> update = [
await this.RunStreamingAsync(messages, thread, options, cancellationToken)
await this.RunCoreStreamingAsync(messages, thread, options, cancellationToken)
.SingleAsync(cancellationToken)
.ConfigureAwait(false)];
return update.ToAgentRunResponse();
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
yield return new(ChatRole.Assistant, "Hello World!")
{
@@ -30,7 +30,7 @@ internal sealed class HandoffTestEchoAgent(string id, string name, string prefix
{
return [new(ChatRole.Assistant, [new FunctionCallContent(Guid.NewGuid().ToString("N"), handoff.Name)])
{
AuthorName = this.DisplayName,
AuthorName = this.Name ?? this.Id,
MessageId = Guid.NewGuid().ToString("N"),
CreatedAt = DateTime.UtcNow
}];
@@ -62,14 +62,14 @@ public class SpecializedExecutorSmokeTests
public List<ChatMessage> Messages { get; } = Validate(messages) ?? [];
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) =>
Task.FromResult(new AgentRunResponse(this.Messages)
{
AgentId = this.Id,
ResponseId = Guid.NewGuid().ToString("N")
});
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> 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)
@@ -47,7 +47,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
select
UpdateThread(new ChatMessage(ChatRole.Assistant, $"{prefix}{message.Text}")
{
AuthorName = this.DisplayName,
AuthorName = this.Name ?? this.Id,
CreatedAt = DateTimeOffset.Now,
MessageId = Guid.NewGuid().ToString("N")
}, thread as InMemoryAgentThread);
@@ -60,7 +60,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
return [];
}
public override Task<AgentRunResponse> RunAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
protected override Task<AgentRunResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
AgentRunResponse result =
new(this.EchoMessages(messages, thread, options).ToList())
@@ -73,7 +73,7 @@ internal class TestEchoAgent(string? id = null, string? name = null, string? pre
return Task.FromResult(result);
}
public override async IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
protected override async IAsyncEnumerable<AgentRunResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentThread? thread = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string responseId = Guid.NewGuid().ToString("N");