Compare commits

..
Author SHA1 Message Date
Shyju Krishnankutty ad4b732741 Adding ReflectExecutors method to Workflow. 2026-01-22 12:07:03 -08:00
Dmytro StrukandGitHub b4a71f00a3 Updated package versions (#3335) 2026-01-21 18:40:36 +00:00
SukeeshandGitHub 082f39e77e Python: feat(anthropic): Add response_format support for structured outputs (#3301)
* fix(anthropic): Add response_format support for structured outputs

* only use from options

* use native way of response format

* ruff lint fix

* address comment; handle dict
2026-01-21 15:08:13 +00:00
CopilotGitHubstephentoubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Roger Barreto
6f1ab66795 .NET: Fix DebuggerDisplay attribute in AIAgent.cs to reference existing properties (#2985)
* Initial plan

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
2026-01-21 13:50:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d402d92a47 Bump pyasn1 from 0.6.1 to 0.6.2 in /python (#3257)
Bumps [pyasn1](https://github.com/pyasn1/pyasn1) from 0.6.1 to 0.6.2.
- [Release notes](https://github.com/pyasn1/pyasn1/releases)
- [Changelog](https://github.com/pyasn1/pyasn1/blob/main/CHANGES.rst)
- [Commits](https://github.com/pyasn1/pyasn1/compare/v0.6.1...v0.6.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 12:56:40 +00:00
d55dd5f253 .NET: Improve readme for agents V2 (#3285)
* Improve readme for agents V2

* Architectural justification

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-21 12:55:11 +00:00
Giles OdigweandGitHub 88e0ee1a2c Python: Fix local MCP tools with AzureAIProjectAgentProvider (#3315)
* azureai v2 local mcp fix

* addressed copilot comments
2026-01-21 12:43:05 +00:00
Roger BarretoandGitHub aa6579f38c .NET: Update Conversation Sample to use Conversation Id instead (#3180)
* Update Conversation Sample to use conversation Id instead

* Remove Run infix

* Remove the sync GetAIAgent from sample
2026-01-21 12:15:13 +00:00
41cc34421f .NET: Add sample to show multiple AIContextProvider usage (#3284)
* Add sample to show multiple AIContextProvider usage

* Update comment.

* Update messaging in README.

* Address PR comments.

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
2026-01-21 11:42:16 +00:00
CopilotGitHubrogerbarretocopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
eac8baac09 .Net: Fix DebuggerDisplay attribute to reference existing property (#3326)
* Initial plan

* Fix DebuggerDisplay attribute to use Name instead of DisplayName

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
2026-01-21 11:42:01 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
77236bf0ec Bump tomli from 2.3.0 to 2.4.0 in /python (#3182)
Bumps [tomli](https://github.com/hukkin/tomli) from 2.3.0 to 2.4.0.
- [Changelog](https://github.com/hukkin/tomli/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hukkin/tomli/compare/2.3.0...2.4.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-21 05:38:12 +00:00
Evan MattsonandGitHub 6d7690e485 Python: fix(ag-ui): properly handle json serialize with handoff workflows as agent (#3275)
* fix(ag-ui): properly handle json serialize with handoff workflows as agent

* Other improvements around handling non-serializable objects
2026-01-21 02:43:14 +00:00
Evan MattsonandGitHub 6b5437e4ec Python: fix(core): handle anyio cancel scope errors during MCP connection cleanup (#3277)
* fix(core): handle anyio cancel scope errors during MCP connection cleanup

* Address Copilot feedback
2026-01-21 01:49:07 +00:00
db8a59bd3d .NET: Durable Agent samples and automated validation for non-Azure Functions (#3042)
* Durable Agent samples and automated validation for non-Azure Functions

* Update test projects

* fix file encoding

* Remove AgentThreadMetadata usage

* Absorb breaking change from #3152

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

* Absorb more breaking changes (see #3222)

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

* Fix flakey streaming test

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
2026-01-20 22:45:10 +00:00
Eduard van ValkenburgandGitHub 83e6229c11 Python: [Breaking] Simplified Content types to a single class with classmethod constructors. (#3252)
* ported Content to a new model

* fixed linting

* fixes

* fixed data format handling

* fix for 3.10 mypy

* fix

* fix int test
2026-01-20 22:09:39 +00:00
73761aa4a3 .NET: Pass AdditionalProperties from parent to child when exposing an agent as a FunctionTool (#3219)
* Pass AdditionalProperties from parent to child when exposing an agent as a FunctionTool

* Rename variable to improve readability.

* Apply suggestions from code review

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 18:20:08 +00:00
CopilotGitHubstephentoubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
742937194a .NET: Update Microsoft.Extensions.AI.* packages to 10.2.0 (#3211)
* Initial plan

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
2026-01-20 17:52:58 +00:00
74401266e6 Improve PR number handling in workflow (#3302)
* Improve PR number handling in workflow

Refine PR number extraction and validation method.

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

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

* Fix error message for invalid PR number

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-20 14:05:15 +00:00
f8c84d4ee6 Python: Fix: Add system_instructions to ChatClient LLM span tracing (#3164)
* Fix: Add system_instructions to ChatClient LLM span tracing

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

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

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

Fixes #3163

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

* Add edge case tests for system_instructions

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

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

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 14:04:31 +00:00
westeyandGitHub 3ec881509c .NET: Delete sync extension methods for agent (#3291)
* Delete sync extension methods for agent

* Fix comments and obsolete attribute

* Remove more sync methods.

* Fix naming and comments.

* Fix unit tests
2026-01-20 11:24:58 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8ee379d344 Bump tar from 7.4.3 to 7.5.3 in /python/packages/devui/frontend (#3267)
Bumps [tar](https://github.com/isaacs/node-tar) from 7.4.3 to 7.5.3.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.4.3...v7.5.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 07:26:34 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2a43caefaa Bump ruff from 0.14.11 to 0.14.13 in /python (#3287)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.11 to 0.14.13.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.11...0.14.13)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.14.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 07:26:06 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f54248b79f Bump uv from 0.9.25 to 0.9.26 in /python (#3288)
Bumps [uv](https://github.com/astral-sh/uv) from 0.9.25 to 0.9.26.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.9.25...0.9.26)

---
updated-dependencies:
- dependency-name: uv
  dependency-version: 0.9.26
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-20 07:25:45 +00:00
Victor DibiaandGitHub 0f29637b86 fix #3171, ensure proper form rendering for int (#3201) 2026-01-20 07:25:12 +00:00
Evan MattsonandGitHub e0b9be7e08 Python: fix(declarative): Fix MCP tool connection not passed from YAML to Azure AI agent creation API (#3248)
* fix(declarative): Fix MCP tool connection not passed from YAML

* Add samples to README

* Fix mypy

* Fix mypy again

* Address PR comments
2026-01-20 07:24:20 +00:00
Dmytro StrukandGitHub 83e8965c8e Python: [BREAKING] Make response_format validation errors visible to users (#3274)
* Make response_format validation errors visible to users

* Small fix

* Addressed comments
2026-01-19 17:20:46 +00:00
Mark WallaceandGitHub 3c1be2a713 Update ignored checks in merge-gatekeeper workflow 2026-01-19 16:17:24 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
467d3a60ed Bump actions/setup-dotnet from 5.0.1 to 5.1.0 (#3273)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.0.1 to 5.1.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.0.1...v5.1.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 15:58:02 +00:00
3243652df6 Python: Filter conversation_id when passing kwargs to agent as tool (#3266)
* Filter conversation_id when passing kwargs to agent as tool

* Small fix

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

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

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

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

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

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-19 12:54:06 +00:00
Dmytro StrukandGitHub 915df3b404 Python: Added rai_config to Azure AI agent creation (#3265)
* Add kwargs to create_agent method

* Added test for kwargs

* Addressed comment

* Added doc string
2026-01-19 12:51:27 +00:00
Dmytro StrukandGitHub f87e55ba33 Python: Fixed use_agent_middleware calling private _normalize_messages (#3264)
* Fix use_agent_middleware calling private _normalize_messages

* Fixed A2A and Copilot Studio agent
2026-01-19 12:50:14 +00:00
Dmytro StrukandGitHub 9bfa1a913c Python: Fixed Azure chat client for asynchronous filtering (#3260)
* Fixed Azure chat client for asynchronous filtering

* Updated test
2026-01-19 12:49:23 +00:00
238 changed files with 11435 additions and 7836 deletions
@@ -12,7 +12,7 @@ runs:
docker rm -f dts-emulator
fi
echo "Starting Durable Task Scheduler Emulator"
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
echo "Waiting for Durable Task Scheduler Emulator to be ready"
timeout 30 bash -c 'until curl --silent http://localhost:8080/healthz; do sleep 1; done'
echo "Durable Task Scheduler Emulator is ready"
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.0.1
uses: actions/setup-dotnet@v5.1.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
+1 -1
View File
@@ -29,4 +29,4 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
timeout: 3600
interval: 30
ignored: CodeQL
ignored: CodeQL,CodeQL analysis (csharp)
@@ -34,9 +34,16 @@ jobs:
# because the workflow_run event does not have access to the PR number
# The PR number is needed to post the comment on the PR
run: |
PR_NUMBER=$(cat pr_number)
echo "PR number: $PR_NUMBER"
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
if [ ! -s pr_number ]; then
echo "PR number file 'pr_number' is missing or empty"
exit 1
fi
PR_NUMBER=$(head -1 pr_number | tr -dc '0-9')
if [ -z "$PR_NUMBER" ]; then
echo "PR number file 'pr_number' does not contain a valid PR number"
exit 1
fi
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@v1.2.0
+9 -9
View File
@@ -33,18 +33,18 @@
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.1" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.1" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.2" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.0" />
<PackageVersion Include="System.Text.Json" Version="10.0.1" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.1" />
<PackageVersion Include="System.Text.Json" Version="10.0.2" />
<PackageVersion Include="System.Threading.Channels" Version="10.0.2" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
<!-- OpenTelemetry -->
@@ -61,9 +61,9 @@
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.1.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.2.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.2.0" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.2.0-preview.1.26063.2" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
@@ -71,11 +71,11 @@
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
+13
View File
@@ -35,6 +35,18 @@
<Project Path="samples/AzureFunctions/07_AgentAsMcpTool/07_AgentAsMcpTool.csproj" />
<Project Path="samples/AzureFunctions/08_ReliableStreaming/08_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/DurableAgents/">
<File Path="samples/DurableAgents/ConsoleApps/README.md" />
</Folder>
<Folder Name="/Samples/DurableAgents/ConsoleApps/">
<Project Path="samples/DurableAgents/ConsoleApps/01_SingleAgent/01_SingleAgent.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining/02_AgentOrchestration_Chaining.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency/03_AgentOrchestration_Concurrency.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals/04_AgentOrchestration_Conditionals.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL/05_AgentOrchestration_HITL.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/06_LongRunningTools/06_LongRunningTools.csproj" />
<Project Path="samples/DurableAgents/ConsoleApps/07_ReliableStreaming/07_ReliableStreaming.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
</Folder>
@@ -81,6 +93,7 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Agent_Step18_DeepResearch.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Declarative/Agent_Step19_Declarative.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step20_AdditionalAIContext/Agent_Step20_AdditionalAIContext.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/DeclarativeAgents/">
<Project Path="samples/GettingStarted/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260108.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260108.1</PackageVersion>
<GitTag>1.0.0-preview.260108.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260121.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260121.1</PackageVersion>
<GitTag>1.0.0-preview.260121.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,103 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Set up an AI agent following the standard Microsoft Agent Framework pattern.
const string JokerName = "Joker";
const string JokerInstructions = "You are good at telling jokes.";
AIAgent agent = client.GetChatClient(deploymentName).AsAIAgent(JokerInstructions, JokerName);
// Configure the console app to host the AI agent.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1)),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
// Get the agent proxy from services
IServiceProvider services = host.Services;
AIAgent agentProxy = services.GetRequiredKeyedService<AIAgent>(JokerName);
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Single Agent Console Sample ===");
Console.ResetColor();
Console.WriteLine("Enter a message for the Joker agent (or 'exit' to quit):");
Console.WriteLine();
// Create a thread for the conversation
AgentThread thread = await agentProxy.GetNewThreadAsync();
while (true)
{
// Read input from stdin
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("You: ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
// Run the agent
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("Joker: ");
Console.ResetColor();
try
{
AgentResponse agentResponse = await agentProxy.RunAsync(
message: input,
thread: thread,
cancellationToken: CancellationToken.None);
Console.WriteLine(agentResponse.Text);
Console.WriteLine();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Console.WriteLine();
}
}
await host.StopAsync();
@@ -0,0 +1,56 @@
# Single Agent Sample
This sample demonstrates how to use the durable agents extension to create a simple console app that hosts a single AI agent and provides interactive conversation via stdin/stdout.
## Key Concepts Demonstrated
- Using the Microsoft Agent Framework to define a simple AI agent with a name and instructions.
- Registering durable agents with the console app and running them interactively.
- Conversation management (via threads) for isolated interactions.
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/01_SingleAgent
dotnet run --framework net10.0
```
The app will prompt you for input. You can interact with the Joker agent:
```text
=== Single Agent Console Sample ===
Enter a message for the Joker agent (or 'exit' to quit):
You: Tell me a joke about a pirate.
Joker: Why don't pirates ever learn the alphabet? Because they always get stuck at "C"!
You: Now explain the joke.
Joker: The joke plays on the word "sea" (C), which pirates are famously associated with...
You: exit
```
## Scriptable Usage
You can also pipe input to the app for scriptable usage:
```bash
echo "Tell me a joke about a pirate." | dotnet run
```
The app will read from stdin, process the input, and write the response to stdout.
## Viewing Agent State
You can view the state of the agent in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can view the state of the Joker agent, including its conversation history and current state
The agent maintains conversation state across multiple interactions, and you can inspect this state in the dashboard to understand how the durable agents extension manages conversation context.
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>AgentOrchestration_Chaining</AssemblyName>
<RootNamespace>AgentOrchestration_Chaining</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentOrchestration_Chaining;
// Response model
public sealed record TextResponse(string Text);
@@ -0,0 +1,148 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentOrchestration_Chaining;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using Environment = System.Environment;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Single agent used by the orchestration to demonstrate sequential calls on the same thread.
const string WriterName = "WriterAgent";
const string WriterInstructions =
"""
You refine short pieces of text. When given an initial sentence you enhance it;
when given an improved sentence you polish it further.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterInstructions, WriterName);
// Orchestrator function
static async Task<string> RunOrchestratorAsync(TaskOrchestrationContext context)
{
DurableAIAgent writer = context.GetAgent("WriterAgent");
AgentThread writerThread = await writer.GetNewThreadAsync();
AgentResponse<TextResponse> initial = await writer.RunAsync<TextResponse>(
message: "Write a concise inspirational sentence about learning.",
thread: writerThread);
AgentResponse<TextResponse> refined = await writer.RunAsync<TextResponse>(
message: $"Improve this further while keeping it under 25 words: {initial.Result.Text}",
thread: writerThread);
return refined.Result.Text;
}
// Configure the console app to host the AI agent.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options => options.AddAIAgent(writerAgent),
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
builder.AddTasks(registry => registry.AddOrchestratorFunc(nameof(RunOrchestratorAsync), RunOrchestratorAsync));
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableClient = host.Services.GetRequiredService<DurableTaskClient>();
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Single Agent Orchestration Chaining Sample ===");
Console.ResetColor();
Console.WriteLine("Starting orchestration...");
Console.WriteLine();
try
{
// Start the orchestration
string instanceId = await durableClient.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestratorAsync));
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
Console.WriteLine("Waiting for completion...");
Console.ResetColor();
// Wait for orchestration to complete
OrchestrationMetadata status = await durableClient.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
CancellationToken.None);
Console.WriteLine();
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Orchestration completed successfully!");
Console.ResetColor();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("Result: ");
Console.ResetColor();
Console.WriteLine(status.ReadOutputAs<string>());
}
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("✗ Orchestration failed!");
Console.ResetColor();
if (status.FailureDetails != null)
{
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
}
Environment.Exit(1);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Environment.Exit(1);
}
finally
{
await host.StopAsync();
}
@@ -0,0 +1,53 @@
# Single Agent Orchestration Sample
This sample demonstrates how to use the durable agents extension to create a simple console app that orchestrates sequential calls to a single AI agent using the same conversation thread for context continuity.
## Key Concepts Demonstrated
- Orchestrating multiple interactions with the same agent in a deterministic order
- Using the same `AgentThread` across multiple calls to maintain conversational context
- Durable orchestration with automatic checkpointing and resumption from failures
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/02_AgentOrchestration_Chaining
dotnet run --framework net10.0
```
The app will start the orchestration, wait for it to complete, and display the result:
```text
=== Single Agent Orchestration Chaining Sample ===
Starting orchestration...
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
Waiting for completion...
✓ Orchestration completed successfully!
Result: Learning serves as the key, opening doors to boundless opportunities and a brighter future.
```
The orchestration will proceed to run the WriterAgent twice in sequence:
1. First, it writes an inspirational sentence about learning
2. Then, it refines the initial output using the same conversation thread
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
- **Agents**: View the state of the WriterAgent, including conversation history maintained across the orchestration steps
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect its execution details, including the sequence of agent calls and their results.
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>AgentOrchestration_Concurrency</AssemblyName>
<RootNamespace>AgentOrchestration_Concurrency</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
namespace AgentOrchestration_Concurrency;
// Response model
public sealed record TextResponse(string Text);
@@ -0,0 +1,191 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using AgentOrchestration_Concurrency;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Two agents used by the orchestration to demonstrate concurrent execution.
const string PhysicistName = "PhysicistAgent";
const string PhysicistInstructions = "You are an expert in physics. You answer questions from a physics perspective.";
const string ChemistName = "ChemistAgent";
const string ChemistInstructions = "You are a middle school chemistry teacher. You answer questions so that middle school students can understand.";
AIAgent physicistAgent = client.GetChatClient(deploymentName).AsAIAgent(PhysicistInstructions, PhysicistName);
AIAgent chemistAgent = client.GetChatClient(deploymentName).AsAIAgent(ChemistInstructions, ChemistName);
// Orchestrator function
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, string prompt)
{
// Get both agents
DurableAIAgent physicist = context.GetAgent(PhysicistName);
DurableAIAgent chemist = context.GetAgent(ChemistName);
// Start both agent runs concurrently
Task<AgentResponse<TextResponse>> physicistTask = physicist.RunAsync<TextResponse>(prompt);
Task<AgentResponse<TextResponse>> chemistTask = chemist.RunAsync<TextResponse>(prompt);
// Wait for both tasks to complete using Task.WhenAll
await Task.WhenAll(physicistTask, chemistTask);
// Get the results
TextResponse physicistResponse = (await physicistTask).Result;
TextResponse chemistResponse = (await chemistTask).Result;
// Return the result as a structured, anonymous type
return new
{
physicist = physicistResponse.Text,
chemist = chemistResponse.Text,
};
}
// Configure the console app to host the AI agents.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options =>
{
options
.AddAIAgent(physicistAgent)
.AddAIAgent(chemistAgent);
},
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
builder.AddTasks(
registry => registry.AddOrchestratorFunc<string, object>(nameof(RunOrchestratorAsync), RunOrchestratorAsync));
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Multi-Agent Concurrent Orchestration Sample ===");
Console.ResetColor();
Console.WriteLine("Enter a question for the agents:");
Console.WriteLine();
// Read prompt from stdin
string? prompt = Console.ReadLine();
if (string.IsNullOrWhiteSpace(prompt))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine("Error: Prompt is required.");
Console.ResetColor();
Environment.Exit(1);
return;
}
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine("Starting orchestration...");
Console.ResetColor();
try
{
// Start the orchestration
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestratorAsync),
input: prompt);
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
Console.WriteLine("Waiting for completion...");
Console.ResetColor();
// Wait for orchestration to complete
OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
CancellationToken.None);
Console.WriteLine();
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Orchestration completed successfully!");
Console.ResetColor();
Console.WriteLine();
// Parse the output
using JsonDocument doc = JsonDocument.Parse(status.SerializedOutput!);
JsonElement output = doc.RootElement;
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Physicist's response:");
Console.ResetColor();
Console.WriteLine(output.GetProperty("physicist").GetString());
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Chemist's response:");
Console.ResetColor();
Console.WriteLine(output.GetProperty("chemist").GetString());
}
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("✗ Orchestration failed!");
Console.ResetColor();
if (status.FailureDetails != null)
{
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
}
Environment.Exit(1);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Environment.Exit(1);
}
finally
{
await host.StopAsync();
}
@@ -0,0 +1,68 @@
# Multi-Agent Concurrent Orchestration Sample
This sample demonstrates how to use the durable agents extension to create a console app that orchestrates concurrent execution of multiple AI agents using durable orchestration.
## Key Concepts Demonstrated
- Running multiple agents concurrently in a single orchestration
- Using `Task.WhenAll` to wait for concurrent agent executions
- Combining results from multiple agents into a single response
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/03_AgentOrchestration_Concurrency
dotnet run --framework net10.0
```
The app will prompt you for a question:
```text
=== Multi-Agent Concurrent Orchestration Sample ===
Enter a question for the agents:
What is temperature?
```
The orchestration will run both agents concurrently and display their responses:
```text
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
Waiting for completion...
✓ Orchestration completed successfully!
Physicist's response:
Temperature is a measure of the average kinetic energy of particles in a system...
Chemist's response:
From a chemistry perspective, temperature is crucial for chemical reactions...
```
Both agents run in parallel, and the orchestration waits for both to complete before returning the combined results.
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
- **Agents**: View the state of both the PhysicistAgent and ChemistAgent, including their individual conversation histories
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect how the concurrent agent executions were coordinated, including the timing of when each agent started and completed.
## Scriptable Usage
You can also pipe input to the app:
```bash
echo "What is temperature?" | dotnet run
```
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>AgentOrchestration_Conditionals</AssemblyName>
<RootNamespace>AgentOrchestration_Conditionals</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AgentOrchestration_Conditionals;
/// <summary>
/// Represents an email input for spam detection and response generation.
/// </summary>
public sealed class Email
{
[JsonPropertyName("email_id")]
public string EmailId { get; set; } = string.Empty;
[JsonPropertyName("email_content")]
public string EmailContent { get; set; } = string.Empty;
}
/// <summary>
/// Represents the result of spam detection analysis.
/// </summary>
public sealed class DetectionResult
{
[JsonPropertyName("is_spam")]
public bool IsSpam { get; set; }
[JsonPropertyName("reason")]
public string Reason { get; set; } = string.Empty;
}
/// <summary>
/// Represents a generated email response.
/// </summary>
public sealed class EmailResponse
{
[JsonPropertyName("response")]
public string Response { get; set; } = string.Empty;
}
@@ -0,0 +1,228 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentOrchestration_Conditionals;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Spam detection agent
const string SpamDetectionAgentName = "SpamDetectionAgent";
const string SpamDetectionAgentInstructions =
"""
You are an expert email spam detection system. Analyze emails and determine if they are spam.
Return your analysis as JSON with 'is_spam' (boolean) and 'reason' (string) fields.
""";
// Email assistant agent
const string EmailAssistantAgentName = "EmailAssistantAgent";
const string EmailAssistantAgentInstructions =
"""
You are a professional email assistant. Draft professional, courteous, and helpful email responses.
Return your response as JSON with a 'response' field containing the reply.
""";
AIAgent spamDetectionAgent = client.GetChatClient(deploymentName).AsAIAgent(SpamDetectionAgentInstructions, SpamDetectionAgentName);
AIAgent emailAssistantAgent = client.GetChatClient(deploymentName).AsAIAgent(EmailAssistantAgentInstructions, EmailAssistantAgentName);
// Orchestrator function
static async Task<string> RunOrchestratorAsync(TaskOrchestrationContext context, Email email)
{
// Get the spam detection agent
DurableAIAgent spamDetectionAgent = context.GetAgent(SpamDetectionAgentName);
AgentThread spamThread = await spamDetectionAgent.GetNewThreadAsync();
// Step 1: Check if the email is spam
AgentResponse<DetectionResult> spamDetectionResponse = await spamDetectionAgent.RunAsync<DetectionResult>(
message:
$"""
Analyze this email for spam content and return a JSON response with 'is_spam' (boolean) and 'reason' (string) fields:
Email ID: {email.EmailId}
Content: {email.EmailContent}
""",
thread: spamThread);
DetectionResult result = spamDetectionResponse.Result;
// Step 2: Conditional logic based on spam detection result
if (result.IsSpam)
{
// Handle spam email
return await context.CallActivityAsync<string>(nameof(HandleSpamEmail), result.Reason);
}
// Generate and send response for legitimate email
DurableAIAgent emailAssistantAgent = context.GetAgent(EmailAssistantAgentName);
AgentThread emailThread = await emailAssistantAgent.GetNewThreadAsync();
AgentResponse<EmailResponse> emailAssistantResponse = await emailAssistantAgent.RunAsync<EmailResponse>(
message:
$"""
Draft a professional response to this email. Return a JSON response with a 'response' field containing the reply:
Email ID: {email.EmailId}
Content: {email.EmailContent}
""",
thread: emailThread);
EmailResponse emailResponse = emailAssistantResponse.Result;
return await context.CallActivityAsync<string>(nameof(SendEmail), emailResponse.Response);
}
// Activity functions
static void HandleSpamEmail(TaskActivityContext context, string reason)
{
Console.WriteLine($"Email marked as spam: {reason}");
}
static void SendEmail(TaskActivityContext context, string message)
{
Console.WriteLine($"Email sent: {message}");
}
// Configure the console app to host the AI agents.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options =>
{
options
.AddAIAgent(spamDetectionAgent)
.AddAIAgent(emailAssistantAgent);
},
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
builder.AddTasks(registry =>
{
registry.AddOrchestratorFunc<Email>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
registry.AddActivityFunc<string>(nameof(HandleSpamEmail), HandleSpamEmail);
registry.AddActivityFunc<string>(nameof(SendEmail), SendEmail);
});
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Multi-Agent Conditional Orchestration Sample ===");
Console.ResetColor();
Console.WriteLine("Enter email content:");
Console.WriteLine();
// Read email content from stdin
string? emailContent = Console.ReadLine();
if (string.IsNullOrWhiteSpace(emailContent))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine("Error: Email content is required.");
Console.ResetColor();
Environment.Exit(1);
return;
}
// Generate email ID automatically
Email email = new()
{
EmailId = $"email-{Guid.NewGuid():N}",
EmailContent = emailContent
};
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine("Starting orchestration...");
Console.ResetColor();
try
{
// Start the orchestration
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestratorAsync),
input: email);
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
Console.WriteLine("Waiting for completion...");
Console.ResetColor();
// Wait for orchestration to complete
OrchestrationMetadata status = await durableTaskClient.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
CancellationToken.None);
Console.WriteLine();
if (status.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Orchestration completed successfully!");
Console.ResetColor();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("Result: ");
Console.ResetColor();
Console.WriteLine(status.ReadOutputAs<string>());
}
else if (status.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("✗ Orchestration failed!");
Console.ResetColor();
if (status.FailureDetails != null)
{
Console.WriteLine($"Error: {status.FailureDetails.ErrorMessage}");
}
Environment.Exit(1);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Orchestration status: {status.RuntimeStatus}");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Environment.Exit(1);
}
finally
{
await host.StopAsync();
}
@@ -0,0 +1,95 @@
# Multi-Agent Conditional Orchestration Sample
This sample demonstrates how to use the durable agents extension to create a console app that orchestrates multiple AI agents with conditional logic based on the results of previous agent interactions.
## Key Concepts Demonstrated
- Multi-agent orchestration with conditional branching
- Using agent responses to determine workflow paths
- Activity functions for non-agent operations
- Waiting for orchestration completion using `WaitForInstanceCompletionAsync`
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/04_AgentOrchestration_Conditionals
dotnet run --framework net10.0
```
The app will prompt you for email content. You can test both legitimate emails and spam emails:
### Testing with a Legitimate Email
```text
=== Multi-Agent Conditional Orchestration Sample ===
Enter email content:
Hi John, I hope you're doing well. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!
```
The orchestration will analyze the email and display the result:
```text
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
Waiting for completion...
✓ Orchestration completed successfully!
Result: Email sent: Thank you for your email. I'll prepare the updated figures...
```
### Testing with a Spam Email
```text
=== Multi-Agent Conditional Orchestration Sample ===
Enter email content:
URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!
```
The orchestration will detect it as spam and display:
```text
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
Waiting for completion...
✓ Orchestration completed successfully!
Result: Email marked as spam: Contains suspicious claims about winning money and urgent action requests...
```
## Scriptable Usage
You can also pipe email content to the app:
```bash
# Test with a legitimate email
echo "Hi John, I hope you're doing well..." | dotnet run
# Test with a spam email
echo "URGENT! You've won $1,000,000! Click here now!" | dotnet run
```
The orchestration will proceed as follows:
1. The SpamDetectionAgent analyzes the email to determine if it's spam
2. Based on the result:
- If spam: The orchestration calls the `HandleSpamEmail` activity function
- If not spam: The EmailAssistantAgent drafts a response, then the `SendEmail` activity function is called
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Orchestrations**: View the orchestration instance, including its runtime status, input, output, and execution history
- **Agents**: View the state of both the SpamDetectionAgent and EmailAssistantAgent
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect the conditional branching logic, including which path was taken based on the spam detection result.
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>AgentOrchestration_HITL</AssemblyName>
<RootNamespace>AgentOrchestration_HITL</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace AgentOrchestration_HITL;
/// <summary>
/// Represents the input for the Human-in-the-Loop content generation workflow.
/// </summary>
public sealed class ContentGenerationInput
{
[JsonPropertyName("topic")]
public string Topic { get; set; } = string.Empty;
[JsonPropertyName("max_review_attempts")]
public int MaxReviewAttempts { get; set; } = 3;
[JsonPropertyName("approval_timeout_hours")]
public float ApprovalTimeoutHours { get; set; } = 72;
}
/// <summary>
/// Represents the content generated by the writer agent.
/// </summary>
public sealed class GeneratedContent
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// Represents the human approval response.
/// </summary>
public sealed class HumanApprovalResponse
{
[JsonPropertyName("approved")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
public string Feedback { get; set; } = string.Empty;
}
@@ -0,0 +1,333 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using AgentOrchestration_HITL;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Single agent used by the orchestration to demonstrate human-in-the-loop workflow.
const string WriterName = "WriterAgent";
const string WriterInstructions =
"""
You are a professional content writer who creates high-quality articles on various topics.
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterInstructions, WriterName);
// Orchestrator function
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input)
{
// Get the writer agent
DurableAIAgent writerAgent = context.GetAgent("WriterAgent");
AgentThread writerThread = await writerAgent.GetNewThreadAsync();
// Set initial status
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
// Step 1: Generate initial content
AgentResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"Write a short article about '{input.Topic}' in less than 300 words.",
thread: writerThread);
GeneratedContent content = writerResponse.Result;
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
int iterationCount = 0;
while (iterationCount++ < input.MaxReviewAttempts)
{
context.SetCustomStatus(
$"Requesting human feedback. Iteration #{iterationCount}. Timeout: {input.ApprovalTimeoutHours} hour(s).");
// Step 2: Notify user to review the content
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
// Step 3: Wait for human feedback with configurable timeout
HumanApprovalResponse humanResponse;
try
{
humanResponse = await context.WaitForExternalEvent<HumanApprovalResponse>(
eventName: "HumanApproval",
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
}
catch (OperationCanceledException)
{
// Timeout occurred - treat as rejection
context.SetCustomStatus(
$"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.");
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
}
if (humanResponse.Approved)
{
context.SetCustomStatus("Content approved by human reviewer. Publishing content...");
// Step 4: Publish the approved content
await context.CallActivityAsync(nameof(PublishContent), content);
context.SetCustomStatus($"Content published successfully at {context.CurrentUtcDateTime:s}");
return new { content = content.Content };
}
context.SetCustomStatus("Content rejected by human reviewer. Incorporating feedback and regenerating...");
// Incorporate human feedback and regenerate
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"""
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
Human Feedback: {humanResponse.Feedback}
""",
thread: writerThread);
content = writerResponse.Result;
}
// If we reach here, it means we exhausted the maximum number of iterations
throw new InvalidOperationException(
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
}
// Activity functions
static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content)
{
// In a real implementation, this would send notifications via email, SMS, etc.
Console.WriteLine(
$"""
NOTIFICATION: Please review the following content for approval:
Title: {content.Title}
Content: {content.Content}
Use the approval endpoint to approve or reject this content.
""");
}
static void PublishContent(TaskActivityContext context, GeneratedContent content)
{
// In a real implementation, this would publish to a CMS, website, etc.
Console.WriteLine(
$"""
PUBLISHING: Content has been published successfully.
Title: {content.Title}
Content: {content.Content}
""");
}
// Configure the console app to host the AI agent.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options => options.AddAIAgent(writerAgent),
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
builder.AddTasks(registry =>
{
registry.AddOrchestratorFunc<ContentGenerationInput>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
registry.AddActivityFunc<GeneratedContent>(nameof(NotifyUserForApproval), NotifyUserForApproval);
registry.AddActivityFunc<GeneratedContent>(nameof(PublishContent), PublishContent);
});
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
DurableTaskClient durableTaskClient = host.Services.GetRequiredService<DurableTaskClient>();
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Human-in-the-Loop Orchestration Sample ===");
Console.ResetColor();
Console.WriteLine("Enter topic for content generation:");
Console.WriteLine();
// Read topic from stdin
string? topic = Console.ReadLine();
if (string.IsNullOrWhiteSpace(topic))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine("Error: Topic is required.");
Console.ResetColor();
Environment.Exit(1);
return;
}
// Prompt for optional parameters with defaults
Console.WriteLine();
Console.WriteLine("Max review attempts (default: 3):");
string? maxAttemptsInput = Console.ReadLine();
int maxReviewAttempts = int.TryParse(maxAttemptsInput, out int maxAttempts) && maxAttempts > 0
? maxAttempts
: 3;
Console.WriteLine("Approval timeout in hours (default: 72):");
string? timeoutInput = Console.ReadLine();
float approvalTimeoutHours = float.TryParse(timeoutInput, out float timeout) && timeout > 0
? timeout
: 72;
ContentGenerationInput input = new()
{
Topic = topic,
MaxReviewAttempts = maxReviewAttempts,
ApprovalTimeoutHours = approvalTimeoutHours
};
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine("Starting orchestration...");
Console.ResetColor();
try
{
// Start the orchestration
string instanceId = await durableTaskClient.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: nameof(RunOrchestratorAsync),
input: input);
Console.ForegroundColor = ConsoleColor.Gray;
Console.WriteLine($"Orchestration started with instance ID: {instanceId}");
Console.WriteLine("Waiting for human approval...");
Console.ResetColor();
Console.WriteLine();
// Monitor orchestration status and handle approval prompts
using CancellationTokenSource cts = new();
Task orchestrationTask = Task.Run(async () =>
{
while (!cts.Token.IsCancellationRequested)
{
OrchestrationMetadata? status = await durableTaskClient.GetInstanceAsync(
instanceId,
getInputsAndOutputs: true,
cts.Token);
if (status == null)
{
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
continue;
}
// Check if we're waiting for approval
if (status.SerializedCustomStatus != null)
{
string? customStatus = status.ReadCustomStatusAs<string>();
if (customStatus?.StartsWith("Requesting human feedback", StringComparison.OrdinalIgnoreCase) == true)
{
// Prompt user for approval
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Content is ready for review. Check the logs above for details.");
Console.Write("Approve? (y/n): ");
Console.ResetColor();
string? approvalInput = Console.ReadLine();
bool approved = approvalInput?.Trim().Equals("y", StringComparison.OrdinalIgnoreCase) == true;
Console.Write("Feedback (optional): ");
string? feedback = Console.ReadLine() ?? "";
HumanApprovalResponse approvalResponse = new()
{
Approved = approved,
Feedback = feedback
};
await durableTaskClient.RaiseEventAsync(instanceId, "HumanApproval", approvalResponse);
}
}
if (status.RuntimeStatus is OrchestrationRuntimeStatus.Completed or OrchestrationRuntimeStatus.Failed or OrchestrationRuntimeStatus.Terminated)
{
break;
}
await Task.Delay(TimeSpan.FromSeconds(1), cts.Token);
}
}, cts.Token);
// Wait for orchestration to complete
OrchestrationMetadata finalStatus = await durableTaskClient.WaitForInstanceCompletionAsync(
instanceId,
getInputsAndOutputs: true,
CancellationToken.None);
cts.Cancel();
await orchestrationTask;
Console.WriteLine();
if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("✓ Orchestration completed successfully!");
Console.ResetColor();
Console.WriteLine();
JsonElement output = finalStatus.ReadOutputAs<JsonElement>();
if (output.TryGetProperty("content", out JsonElement contentElement))
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Published content:");
Console.ResetColor();
Console.WriteLine(contentElement.GetString());
}
}
else if (finalStatus.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("✗ Orchestration failed!");
Console.ResetColor();
if (finalStatus.FailureDetails != null)
{
Console.WriteLine($"Error: {finalStatus.FailureDetails.ErrorMessage}");
}
Environment.Exit(1);
}
else
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Orchestration status: {finalStatus.RuntimeStatus}");
Console.ResetColor();
}
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Environment.Exit(1);
}
finally
{
await host.StopAsync();
}
@@ -0,0 +1,73 @@
# Human-in-the-Loop Orchestration Sample
This sample demonstrates how to use the durable agents extension to create a console app that implements a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
## Key Concepts Demonstrated
- Human-in-the-loop workflows with durable orchestration
- External event handling for human approval/rejection
- Timeout handling for approval requests
- Iterative content refinement based on human feedback
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/05_AgentOrchestration_HITL
dotnet run --framework net10.0
```
The app will prompt you for input:
```text
=== Human-in-the-Loop Orchestration Sample ===
Enter topic for content generation:
The Future of Artificial Intelligence
Max review attempts (default: 3):
3
Approval timeout in hours (default: 72):
72
```
The orchestration will generate content and prompt you for approval:
```text
Orchestration started with instance ID: 86313f1d45fb42eeb50b1852626bf3ff
=== NOTIFICATION: Content Ready for Review ===
Title: The Future of Artificial Intelligence
Content:
[Generated content appears here]
Please review the content above and provide your approval.
Content is ready for review. Check the logs above for details.
Approve? (y/n): n
Feedback (optional): Please add more details about the ethical implications.
```
The orchestration will incorporate your feedback and regenerate the content. Once approved, it will publish and complete.
## Viewing Orchestration State
You can view the state of the orchestration in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Orchestrations**: View the orchestration instance, including its runtime status, custom status (which shows approval state), input, output, and execution history
- **Agents**: View the state of the WriterAgent, including conversation history
The orchestration instance ID is displayed in the console output. You can use this ID to find the specific orchestration in the dashboard and inspect:
- The custom status field, which shows the current state of the approval workflow
- When the orchestration is waiting for external events
- The iteration count and feedback history
- The final published content
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>LongRunningTools</AssemblyName>
<RootNamespace>LongRunningTools</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace LongRunningTools;
/// <summary>
/// Represents the input for the content generation workflow.
/// </summary>
public sealed class ContentGenerationInput
{
[JsonPropertyName("topic")]
public string Topic { get; set; } = string.Empty;
[JsonPropertyName("max_review_attempts")]
public int MaxReviewAttempts { get; set; } = 3;
[JsonPropertyName("approval_timeout_hours")]
public float ApprovalTimeoutHours { get; set; } = 72;
}
/// <summary>
/// Represents the content generated by the writer agent.
/// </summary>
public sealed class GeneratedContent
{
[JsonPropertyName("title")]
public string Title { get; set; } = string.Empty;
[JsonPropertyName("content")]
public string Content { get; set; } = string.Empty;
}
/// <summary>
/// Represents the human feedback response.
/// </summary>
public sealed class HumanFeedbackResponse
{
[JsonPropertyName("approved")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
public string Feedback { get; set; } = string.Empty;
}
@@ -0,0 +1,351 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using LongRunningTools;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
// 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 DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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());
// Agent used by the orchestration to write content.
const string WriterAgentName = "Writer";
const string WriterAgentInstructions =
"""
You are a professional content writer who creates high-quality articles on various topics.
You write engaging, informative, and well-structured content that follows best practices for readability and accuracy.
""";
AIAgent writerAgent = client.GetChatClient(deploymentName).AsAIAgent(WriterAgentInstructions, WriterAgentName);
// Agent that can start content generation workflows using tools
const string PublisherAgentName = "Publisher";
const string PublisherAgentInstructions =
"""
You are a publishing agent that can manage content generation workflows.
You have access to tools to start, monitor, and raise events for content generation workflows.
""";
const string HumanFeedbackEventName = "HumanFeedback";
// Orchestrator function
static async Task<object> RunOrchestratorAsync(TaskOrchestrationContext context, ContentGenerationInput input)
{
// Get the writer agent
DurableAIAgent writerAgent = context.GetAgent(WriterAgentName);
AgentThread writerThread = await writerAgent.GetNewThreadAsync();
// Set initial status
context.SetCustomStatus($"Starting content generation for topic: {input.Topic}");
// Step 1: Generate initial content
AgentResponse<GeneratedContent> writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"Write a short article about '{input.Topic}'.",
thread: writerThread);
GeneratedContent content = writerResponse.Result;
// Human-in-the-loop iteration - we set a maximum number of attempts to avoid infinite loops
int iterationCount = 0;
while (iterationCount++ < input.MaxReviewAttempts)
{
context.SetCustomStatus(
new
{
message = "Requesting human feedback.",
approvalTimeoutHours = input.ApprovalTimeoutHours,
iterationCount,
content
});
// Step 2: Notify user to review the content
await context.CallActivityAsync(nameof(NotifyUserForApproval), content);
// Step 3: Wait for human feedback with configurable timeout
HumanFeedbackResponse humanResponse;
try
{
humanResponse = await context.WaitForExternalEvent<HumanFeedbackResponse>(
eventName: HumanFeedbackEventName,
timeout: TimeSpan.FromHours(input.ApprovalTimeoutHours));
}
catch (OperationCanceledException)
{
// Timeout occurred - treat as rejection
context.SetCustomStatus(
new
{
message = $"Human approval timed out after {input.ApprovalTimeoutHours} hour(s). Treating as rejection.",
iterationCount,
content
});
throw new TimeoutException($"Human approval timed out after {input.ApprovalTimeoutHours} hour(s).");
}
if (humanResponse.Approved)
{
context.SetCustomStatus(new
{
message = "Content approved by human reviewer. Publishing content...",
content
});
// Step 4: Publish the approved content
await context.CallActivityAsync(nameof(PublishContent), content);
context.SetCustomStatus(new
{
message = $"Content published successfully at {context.CurrentUtcDateTime:s}",
humanFeedback = humanResponse,
content
});
return new { content = content.Content };
}
context.SetCustomStatus(new
{
message = "Content rejected by human reviewer. Incorporating feedback and regenerating...",
humanFeedback = humanResponse,
content
});
// Incorporate human feedback and regenerate
writerResponse = await writerAgent.RunAsync<GeneratedContent>(
message: $"""
The content was rejected by a human reviewer. Please rewrite the article incorporating their feedback.
Human Feedback: {humanResponse.Feedback}
""",
thread: writerThread);
content = writerResponse.Result;
}
// If we reach here, it means we exhausted the maximum number of iterations
throw new InvalidOperationException(
$"Content could not be approved after {input.MaxReviewAttempts} iterations.");
}
// Activity functions
static void NotifyUserForApproval(TaskActivityContext context, GeneratedContent content)
{
// In a real implementation, this would send notifications via email, SMS, etc.
Console.ForegroundColor = ConsoleColor.DarkMagenta;
Console.WriteLine(
$"""
NOTIFICATION: Please review the following content for approval:
Title: {content.Title}
Content: {content.Content}
""");
Console.ResetColor();
}
static void PublishContent(TaskActivityContext context, GeneratedContent content)
{
// In a real implementation, this would publish to a CMS, website, etc.
Console.ForegroundColor = ConsoleColor.DarkMagenta;
Console.WriteLine(
$"""
PUBLISHING: Content has been published successfully.
Title: {content.Title}
Content: {content.Content}
""");
Console.ResetColor();
}
// Tools that demonstrate starting orchestrations from agent tool calls.
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
static string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
{
const int MaxReviewAttempts = 3;
const float ApprovalTimeoutHours = 72;
// Schedule the orchestration, which will start running after the tool call completes.
string instanceId = DurableAgentContext.Current.ScheduleNewOrchestration(
name: nameof(RunOrchestratorAsync),
input: new ContentGenerationInput
{
Topic = topic,
MaxReviewAttempts = MaxReviewAttempts,
ApprovalTimeoutHours = ApprovalTimeoutHours
});
return $"Workflow started with instance ID: {instanceId}";
}
[Description("Gets the status of a workflow orchestration and returns a summary of the workflow's current status.")]
static async Task<object> GetWorkflowStatusAsync(
[Description("The instance ID of the workflow to check")] string instanceId,
[Description("Whether to include detailed information")] bool includeDetails = true)
{
// Get the current agent context using the thread-static property
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
instanceId,
includeDetails);
if (status is null)
{
return new
{
instanceId,
error = $"Workflow instance '{instanceId}' not found.",
};
}
return new
{
instanceId = status.InstanceId,
createdAt = status.CreatedAt,
executionStatus = status.RuntimeStatus,
workflowStatus = status.SerializedCustomStatus,
lastUpdatedAt = status.LastUpdatedAt,
failureDetails = status.FailureDetails
};
}
[Description(
"Raises a feedback event for the content generation workflow. If approved, the workflow will be published. " +
"If rejected, the workflow will generate new content.")]
static async Task SubmitHumanFeedbackAsync(
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
[Description("Feedback to submit")] HumanFeedbackResponse feedback)
{
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, HumanFeedbackEventName, feedback);
}
// Configure the console app to host the AI agents.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options =>
{
// Add the writer agent used by the orchestration
options.AddAIAgent(writerAgent);
// Define the agent that can start orchestrations from tool calls
options.AddAIAgentFactory(PublisherAgentName, sp =>
{
return client.GetChatClient(deploymentName).AsAIAgent(
instructions: PublisherAgentInstructions,
name: PublisherAgentName,
services: sp,
tools: [
AIFunctionFactory.Create(StartContentGenerationWorkflow),
AIFunctionFactory.Create(GetWorkflowStatusAsync),
AIFunctionFactory.Create(SubmitHumanFeedbackAsync),
]);
});
},
workerBuilder: builder =>
{
builder.UseDurableTaskScheduler(dtsConnectionString);
builder.AddTasks(registry =>
{
registry.AddOrchestratorFunc<ContentGenerationInput>(nameof(RunOrchestratorAsync), RunOrchestratorAsync);
registry.AddActivityFunc<GeneratedContent>(nameof(NotifyUserForApproval), NotifyUserForApproval);
registry.AddActivityFunc<GeneratedContent>(nameof(PublishContent), PublishContent);
});
},
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
// Get the agent proxy from services
IServiceProvider services = host.Services;
AIAgent? agentProxy = services.GetKeyedService<AIAgent>(PublisherAgentName);
if (agentProxy == null)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine("Agent 'Publisher' not found.");
Console.ResetColor();
Environment.Exit(1);
return;
}
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Long Running Tools Sample ===");
Console.ResetColor();
Console.WriteLine("Enter a topic for the Publisher agent to write about (or 'exit' to quit):");
Console.WriteLine();
// Create a thread for the conversation
AgentThread thread = await agentProxy.GetNewThreadAsync();
using CancellationTokenSource cts = new();
Console.CancelKeyPress += (sender, e) =>
{
e.Cancel = true;
cts.Cancel();
};
while (!cts.Token.IsCancellationRequested)
{
// Read input from stdin
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("You: ");
Console.ResetColor();
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
// Run the agent
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("Publisher: ");
Console.ResetColor();
try
{
AgentResponse agentResponse = await agentProxy.RunAsync(
message: input,
thread: thread,
cancellationToken: cts.Token);
Console.WriteLine(agentResponse.Text);
Console.WriteLine();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Error: {ex.Message}");
Console.ResetColor();
Console.WriteLine();
}
Console.WriteLine("(Press Enter to prompt the Publisher agent again)");
_ = Console.ReadLine();
}
await host.StopAsync();
@@ -0,0 +1,90 @@
# Long Running Tools Sample
This sample demonstrates how to use the durable agents extension to create a console app with agents that have long running tools. This sample builds on the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample by adding a publisher agent that can start and manage content generation workflows. A key difference is that the publisher agent knows the IDs of the workflows it starts, so it can check the status of the workflows and approve or reject them without being explicitly given the context (instance IDs, etc).
## Key Concepts Demonstrated
The same key concepts as the [05_AgentOrchestration_HITL](../05_AgentOrchestration_HITL) sample are demonstrated, but with the following additional concepts:
- **Long running tools**: Using `DurableAgentContext.Current` to start orchestrations from tool calls
- **Multi-agent orchestration**: Agents can start and manage workflows that orchestrate other agents
- **Human-in-the-loop (with delegation)**: The agent acts as an intermediary between the human and the workflow. The human remains in the loop, but delegates to the agent to start the workflow and approve or reject the content.
## 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.
## Running the Sample
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/06_LongRunningTools
dotnet run --framework net10.0
```
The app will prompt you for input. You can interact with the Publisher agent:
```text
=== Long Running Tools Sample ===
Enter a topic for the Publisher agent to write about (or 'exit' to quit):
You: Start a content generation workflow for the topic 'The Future of Artificial Intelligence'
Publisher: The content generation workflow for the topic "The Future of Artificial Intelligence" has been successfully started, and the instance ID is **6a04276e8d824d8d941e1dc4142cc254**. If you need any further assistance or updates on the workflow, feel free to ask!
```
Behind the scenes, the publisher agent will:
1. Start the content generation workflow via a tool call
2. The workflow will generate initial content using the Writer agent and wait for human approval, which will be visible in the terminal
Once the workflow is waiting for human approval, you can send approval or rejection by prompting the publisher agent accordingly.
> [!NOTE]
> You must press Enter after each message to continue the conversation. The sample is set up this way because the workflow is running in the background and may write to the console asynchronously.
To tell the agent to rewrite the content with feedback, you can prompt it to reject the content with feedback.
```text
You: Reject the content with feedback: The article needs more technical depth and better examples.
Publisher: The content has been successfully rejected with the feedback: "The article needs more technical depth and better examples." The workflow will now generate new content based on this feedback.
```
Once you're satisfied with the content, you can approve it for publishing.
```text
You: Approve the content
Publisher: The content has been successfully approved for publishing. If you need any more assistance or have further requests, feel free to let me know!
```
Once the workflow has completed, you can get the status by prompting the publisher agent to give you the status.
```text
You: Get the status of the workflow you previously started
Publisher: The status of the workflow with instance ID **6a04276e8d824d8d941e1dc4142cc254** is as follows:
- **Execution Status:** Completed
- **Created At:** December 22, 2025, 23:08:13 UTC
- **Last Updated At:** December 22, 2025, 23:09:59 UTC
- **Workflow Status:**
- Message: Content published successfully at December 22, 2025, 23:09:59 UTC
- Human Feedback: Approved
```
## Viewing Agent and Orchestration State
You can view the state of both the agent and the orchestrations it starts in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Agents**: View the state of the Publisher agent, including its conversation history and tool call history
- **Orchestrations**: View the content generation orchestration instances that were started by the agent via tool calls, including their runtime status, custom status, input, output, and execution history
When the publisher agent starts a workflow, the orchestration instance ID is included in the agent's response. You can use this ID to find the specific orchestration in the dashboard and inspect:
- The orchestration's execution progress
- When it's waiting for human approval (visible in custom status)
- The content generation workflow state
- The WriterAgent state within the orchestration
This demonstrates how agents can manage long-running workflows and how you can monitor both the agent's state and the workflows it orchestrates.
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ReliableStreaming</AssemblyName>
<RootNamespace>ReliableStreaming</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<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.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,363 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to implement reliable streaming for durable agents using Redis Streams.
// It reads prompts from stdin and streams agent responses to stdout in real-time.
using System.ComponentModel;
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
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.Parse(Environment.GetEnvironmentVariable("REDIS_STREAM_TTL_MINUTES") ?? "10");
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Use Azure Key Credential if provided, otherwise use Azure CLI Credential.
string? azureOpenAiKey = 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.
""";
// Mock travel tools that return hardcoded data for demonstration purposes.
[Description("Gets the weather forecast for a destination on a specific date. Use this to provide weather-aware recommendations in the itinerary.")]
static string GetWeatherForecast(string destination, string date)
{
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),
};
(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)}
""";
}
[Description("Gets local events and activities happening at a destination around a specific date. Use this to suggest timely activities and experiences.")]
static string GetLocalEvents(string destination, string date)
{
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",
],
};
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!
""";
}
static string GetWeatherRecommendation(string condition)
{
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!"
};
}
// Configure the console app to host the AI agent.
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(loggingBuilder => loggingBuilder.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableAgents(
options =>
{
// Define the Travel Planner agent with tools for weather and events
options.AddAIAgentFactory(TravelPlannerName, sp =>
{
return client.GetChatClient(deploymentName).AsAIAgent(
instructions: TravelPlannerInstructions,
name: TravelPlannerName,
services: sp,
tools: [
AIFunctionFactory.Create(GetWeatherForecast),
AIFunctionFactory.Create(GetLocalEvents),
]);
});
},
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
// Register Redis connection as a singleton
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.
services.AddSingleton(sp =>
new RedisStreamResponseHandler(
sp.GetRequiredService<IConnectionMultiplexer>(),
TimeSpan.FromMinutes(redisStreamTtlMinutes)));
services.AddSingleton<IAgentResponseHandler>(sp =>
sp.GetRequiredService<RedisStreamResponseHandler>());
})
.Build();
await host.StartAsync();
// Get the agent proxy from services
IServiceProvider services = host.Services;
AIAgent? agentProxy = services.GetKeyedService<AIAgent>(TravelPlannerName);
RedisStreamResponseHandler streamHandler = services.GetRequiredService<RedisStreamResponseHandler>();
if (agentProxy == null)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"Agent '{TravelPlannerName}' not found.");
Console.ResetColor();
Environment.Exit(1);
return;
}
// Console colors for better UX
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("=== Reliable Streaming Sample ===");
Console.ResetColor();
Console.WriteLine("Enter a travel planning request (or 'exit' to quit):");
Console.WriteLine();
string? lastCursor = null;
async Task ReadStreamTask(string conversationId, string? cursor, CancellationToken cancellationToken)
{
// Initialize lastCursor to the starting cursor position
// This ensures we have a valid cursor even if cancellation happens before any chunks are processed
lastCursor = cursor;
await foreach (StreamChunk chunk in streamHandler.ReadStreamAsync(conversationId, cursor, cancellationToken))
{
if (chunk.Error != null)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.Error.WriteLine($"\n[Error: {chunk.Error}]");
Console.ResetColor();
break;
}
if (chunk.IsDone)
{
Console.WriteLine();
Console.WriteLine();
break;
}
if (chunk.Text != null)
{
Console.Write(chunk.Text);
}
// Always update lastCursor to track the latest entry ID, even if text is null
// This ensures we can resume from the correct position after interruption
if (!string.IsNullOrEmpty(chunk.EntryId))
{
lastCursor = chunk.EntryId;
}
}
}
// New conversation: prompt from stdin
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write("You: ");
Console.ResetColor();
string? prompt = Console.ReadLine();
if (string.IsNullOrWhiteSpace(prompt) || prompt.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
return;
}
// Create a new agent thread
AgentThread thread = await agentProxy.GetNewThreadAsync();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
string conversationId = sessionId.ToString();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Conversation ID: {conversationId}");
Console.WriteLine("Press [Enter] to interrupt the stream.");
Console.ResetColor();
// Run the agent in the background
DurableAgentRunOptions options = new() { IsFireAndForget = true };
await agentProxy.RunAsync(prompt, thread, options, CancellationToken.None);
bool streamCompleted = false;
while (!streamCompleted)
{
// On a key press, cancel the cancellation token to stop the stream
using CancellationTokenSource userCancellationSource = new();
_ = Task.Run(() =>
{
_ = Console.ReadLine();
userCancellationSource.Cancel();
});
try
{
// Start reading the stream and wait for it to complete
await ReadStreamTask(conversationId, lastCursor, userCancellationSource.Token);
streamCompleted = true;
}
catch (OperationCanceledException)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor.");
// Ensure lastCursor is set - if it's still null, we at least have the starting cursor
string cursorValue = lastCursor ?? "(n/a)";
Console.WriteLine($"Last cursor: {cursorValue}");
Console.ResetColor();
// Explicitly flush to ensure the message is written immediately
Console.Out.Flush();
}
if (!streamCompleted)
{
Console.ReadLine();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"Resuming conversation: {conversationId} from cursor: {lastCursor ?? "(beginning)"}");
Console.ResetColor();
}
}
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("Conversation completed.");
Console.ResetColor();
await host.StopAsync();
@@ -0,0 +1,181 @@
# 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
- **Real-time streaming**: Chunks are printed to stdout as they arrive (like `tail -f`)
- **Cursor-based resumption**: Each chunk includes an entry ID 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
## 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
With the environment setup, you can run the sample:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/07_ReliableStreaming
dotnet run --framework net10.0
```
The app will prompt you for a travel planning request:
```text
=== Reliable Streaming Sample ===
Enter a travel planning request (or 'exit' to quit):
You: Plan a 7-day trip to Tokyo, Japan for next month. Include daily activities, restaurant recommendations, and tips for getting around.
```
The agent's response will stream to your console in real-time as chunks arrive from Redis:
```text
Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
Press [Enter] to interrupt the stream.
TravelPlanner: # 7-Day Tokyo Adventure
## Day 1: Arrival and Exploration
...
```
### Demonstrating Stream Interruption and Resumption
This is the key feature of reliable streaming. Follow these steps to see it in action:
1. **Start a stream**: Run the app and enter a travel planning request
2. **Note the conversation ID**: The conversation ID is displayed at the start of the stream (e.g., `Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890`)
3. **Interrupt the stream**: While the agent is still generating text, press **`Enter`** to interrupt. The agent continues running in the background - your messages are being saved to Redis.
4. **Resume the stream**: Press **`Enter`** again to reconnect and resume the stream from the last cursor position. The app will automatically resume from where it left off.
```text
Starting new conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890
Press [Enter] to interrupt the stream.
TravelPlanner: # 7-Day Tokyo Adventure
## Day 1: Arrival and Exploration
[Streaming content...]
[Press Enter to interrupt]
Stream cancelled. Press [Enter] to reconnect and resume the stream from the last cursor.
Last cursor: 1734567890123-0
[Press Enter to resume]
Resuming conversation: @dafx-travelplanner@a1b2c3d4e5f67890abcdef1234567890 from cursor: 1734567890123-0
[Stream continues from where it left off...]
```
## Viewing Agent State
You can view the state of the agent in the Durable Task Scheduler dashboard:
1. Open your browser and navigate to `http://localhost:8082`
2. In the dashboard, you can see:
- **Agents**: View the state of the TravelPlanner agent, including conversation history and current state
- **Orchestrations**: View any orchestrations that may have been triggered by the agent
The conversation ID displayed in the console output (shown as "Starting new conversation: {conversationId}") corresponds to the agent's conversation thread. You can use this to identify the agent in the dashboard and inspect:
- The agent's conversation state
- Tool calls made by the agent (weather and events lookups)
- The streaming response state
Note that while the console app streams responses from Redis, the agent state in DTS shows the underlying durable agent execution, including all tool calls and conversation context.
## Architecture Overview
```text
┌─────────────┐ stdin (prompt) ┌─────────────────────┐
│ Client │ ─────────────────────► │ Console App │
│ (stdin) │ │ (Program.cs) │
└─────────────┘ └──────────────┬──────┘
▲ │
│ stdout (chunks) Signal Entity
│ │
│ ▼
│ ┌─────────────────────┐
│ │ AgentEntity │
│ │ (Durable Entity) │
│ └──────────┬──────────┘
│ │
│ IAgentResponseHandler
│ │
│ ▼
│ ┌─────────────────────┐
│ │ RedisStreamResponse │
│ │ Handler │
│ └──────────┬──────────┘
│ │
│ XADD (write)
│ │
│ ▼
│ ┌─────────────────────┐
└─────────── XREAD (poll) ────────── │ Redis Streams │
│ (Durable Log) │
└─────────────────────┘
```
### Data Flow
1. **Client sends prompt**: The console app reads the prompt from stdin and generates a new agent thread.
2. **Agent invoked**: The durable agent is signaled to run the travel planner agent. This is fire-and-forget from the console app's perspective.
3. **Responses captured**: As the agent generates responses, the `RedisStreamResponseHandler` (implementing `IAgentResponseHandler`) extracts the text from each `AgentRunResponseUpdate` and publishes it to a Redis Stream keyed by the agent session's conversation ID.
4. **Client polls Redis**: The console app streams events by polling the Redis Stream and printing chunks to stdout as they arrive.
5. **Resumption**: If the client interrupts the stream (e.g., by pressing Enter in the sample), it can resume from the last cursor position by providing the conversation ID and cursor to the call to resume the stream.
## 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.
- **Real-time**: Chunks are printed as soon as they arrive from Redis.
### 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.
## 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
```
@@ -0,0 +1,216 @@
// 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="AgentResponseUpdate"/> 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<AgentResponseUpdate> 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
?? 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<AgentSessionId>().ToString();
if (string.IsNullOrEmpty(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 (AgentResponseUpdate 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(AgentResponse 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);
}
}
}
// If we exited the loop due to cancellation, throw to signal the caller
cancellationToken.ThrowIfCancellationRequested();
}
/// <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,109 @@
# Console App Samples
This directory contains samples for console app hosting of durable agents. These samples use standard I/O (stdin/stdout) for interaction, making them both interactive and scriptable.
- **[01_SingleAgent](01_SingleAgent)**: A sample that demonstrates how to host a single conversational agent in a console app and interact with it via stdin/stdout.
- **[02_AgentOrchestration_Chaining](02_AgentOrchestration_Chaining)**: A sample that demonstrates how to host a single conversational agent in a console app and invoke it using a durable orchestration.
- **[03_AgentOrchestration_Concurrency](03_AgentOrchestration_Concurrency)**: A sample that demonstrates how to host multiple agents in a console app and run them concurrently using a durable orchestration.
- **[04_AgentOrchestration_Conditionals](04_AgentOrchestration_Conditionals)**: A sample that demonstrates how to host multiple agents in a console app and run them sequentially using a durable orchestration with conditionals.
- **[05_AgentOrchestration_HITL](05_AgentOrchestration_HITL)**: A sample that demonstrates how to implement a human-in-the-loop workflow using durable orchestration, including interactive approval prompts.
- **[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_ReliableStreaming](07_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
These samples are designed to be run locally in a cloned repository.
### Prerequisites
The following prerequisites are required to run the samples:
- [.NET 10.0 SDK or later](https://dotnet.microsoft.com/download/dotnet)
- [Azure CLI](https://learn.microsoft.com/cli/azure/install-azure-cli) installed and authenticated (`az login`) or an API key for the Azure OpenAI service
- [Azure OpenAI Service](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource) with a deployed model (gpt-4o-mini or better is recommended)
- [Durable Task Scheduler](https://learn.microsoft.com/azure/azure-functions/durable/durable-task-scheduler/develop-with-durable-task-scheduler) (local emulator or Azure-hosted)
- [Docker](https://docs.docker.com/get-docker/) installed if running the Durable Task Scheduler emulator locally
- [Redis](https://redis.io/) (for sample 07 only) - can be run locally using Docker
### Configuring RBAC Permissions for Azure OpenAI
These samples are configured to use the Azure OpenAI service with RBAC permissions to access the model. You'll need to configure the RBAC permissions for the Azure OpenAI service to allow the console app to access the model.
Below is an example of how to configure the RBAC permissions for the Azure OpenAI service to allow the current user to access the model.
Bash (Linux/macOS/WSL):
```bash
az role assignment create \
--assignee "yourname@contoso.com" \
--role "Cognitive Services OpenAI User" \
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
PowerShell:
```powershell
az role assignment create `
--assignee "yourname@contoso.com" `
--role "Cognitive Services OpenAI User" `
--scope /subscriptions/<your-subscription-id>/resourceGroups/<your-resource-group-name>/providers/Microsoft.CognitiveServices/accounts/<your-openai-resource-name>
```
More information on how to configure RBAC permissions for Azure OpenAI can be found in the [Azure OpenAI documentation](https://learn.microsoft.com/azure/ai-services/openai/how-to/create-resource?pivots=cli).
### Setting an API key for the Azure OpenAI service
As an alternative to configuring Azure RBAC permissions, you can set an API key for the Azure OpenAI service by setting the `AZURE_OPENAI_KEY` environment variable.
Bash (Linux/macOS/WSL):
```bash
export AZURE_OPENAI_KEY="your-api-key"
```
PowerShell:
```powershell
$env:AZURE_OPENAI_KEY="your-api-key"
```
### Start Durable Task Scheduler
Most samples use the Durable Task Scheduler (DTS) to support hosted agents and durable orchestrations. DTS also allows you to view the status of orchestrations and their inputs and outputs from a web UI.
To run the Durable Task Scheduler locally, you can use the following `docker` command:
```bash
docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
```
The DTS dashboard will be available at `http://localhost:8080`.
### Environment Configuration
Each sample reads configuration from environment variables. You'll need to set the following environment variables:
```bash
export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
export AZURE_OPENAI_DEPLOYMENT="your-deployment-name"
```
### Running the Console Apps
Navigate to the sample directory and run the console app:
```bash
cd dotnet/samples/DurableAgents/ConsoleApps/01_SingleAgent
dotnet run --framework net10.0
```
> [!NOTE]
> The `--framework` option is required to specify the target framework for the console app because the samples are designed to support multiple target frameworks. If you are using a different target framework, you can specify it with the `--framework` option.
The app will prompt you for input via stdin.
### Viewing the sample output
The console app output is displayed directly in the terminal where you ran `dotnet run`. Agent responses are printed to stdout with subtle color coding for better readability.
You can also see the state of agents and orchestrations in the Durable Task Scheduler dashboard at `http://localhost:8082`.
@@ -0,0 +1,9 @@
<Project>
<Import Project="../Directory.Build.props" />
<!-- Remove the Environment alias from parent Directory.Build.props to allow System.Environment usage -->
<ItemGroup>
<Using Remove="SampleHelpers.SampleEnvironment" />
</ItemGroup>
</Project>
@@ -26,14 +26,14 @@ var createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: J
// agentVersion.Version = <versionNumber>,
// agentVersion.Name = <agentName>
// You can retrieve an AIAgent for an already created server side agent version.
// You can use an AIAgent with an already created server side agent version.
AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion);
// You can also create another AIAgent version by providing the same name with a different definition.
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
// You can also get the AIAgent latest version just providing its name.
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName);
var latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
// The AIAgent version can be accessed via the GetService method.
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.Bot.ObjectModel" />
<PackageReference Include="Microsoft.Bot.ObjectModel.Json" />
<PackageReference Include="Microsoft.Bot.ObjectModel.PowerFx" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,228 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to inject additional AI context into a ChatClientAgent using a custom AIContextProvider component that is attached to the agent.
// The sample also shows how to combine the results from multiple providers into a single class, in order to attach multiple of these to an agent.
// This mechanism can be used for various purposes, such as injecting RAG search results or memories into the agent's context.
// Also note that Agent Framework already provides built-in AIContextProviders for many of these scenarios.
#pragma warning disable CA1869 // Cache and reuse 'JsonSerializerOptions' instances
using System.ComponentModel;
using System.Text;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
using SampleApp;
using MEAI = Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5-mini";
// A sample function to load the next three calendar events for the user.
Func<Task<string[]>> loadNextThreeCalendarEvents = async () =>
{
// In a real implementation, this method would connect to a calendar service
return new string[]
{
"Doctor's appointment today at 15:00",
"Team meeting today at 17:00",
"Birthday party today at 20:00"
};
};
// Create an agent with an AI context provider attached that aggregates two other providers:
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = """
You are a helpful personal assistant.
You manage a TODO list for the user. When the user has completed one of the tasks it can be removed from the TODO list. Only provide the list of TODO items if asked.
You remind users of upcoming calendar events when the user interacts with you.
""" },
ChatMessageStoreFactory = (ctx, ct) => new ValueTask<ChatMessageStore>(new InMemoryChatMessageStore()
// Use WithAIContextProviderMessageRemoval, so that we don't store the messages from the AI context provider in the chat history.
// You may want to store these messages, depending on their content and your requirements.
.WithAIContextProviderMessageRemoval()),
// Add an AI context provider that maintains a todo list for the agent and one that provides upcoming calendar entries.
// Wrap these in an AI context provider that aggregates the other two.
AIContextProviderFactory = (ctx, ct) => new ValueTask<AIContextProvider>(new AggregatingAIContextProvider([
AggregatingAIContextProvider.CreateFactory((jsonElement, jsonSerializerOptions) => new TodoListAIContextProvider(jsonElement, jsonSerializerOptions)),
AggregatingAIContextProvider.CreateFactory((_, _) => new CalendarSearchAIContextProvider(loadNextThreeCalendarEvents))
], ctx.SerializedState, ctx.JsonSerializerOptions)),
});
// Invoke the agent and output the text result.
AgentThread thread = await agent.GetNewThreadAsync();
Console.WriteLine(await agent.RunAsync("I need to pick up milk from the supermarket.", thread) + "\n");
Console.WriteLine(await agent.RunAsync("I need to take Sally for soccer practice.", thread) + "\n");
Console.WriteLine(await agent.RunAsync("I need to make a dentist appointment for Jimmy.", thread) + "\n");
Console.WriteLine(await agent.RunAsync("I've taken Sally to soccer practice.", thread) + "\n");
// We can serialize the thread, and it will contain both the chat history and the data that each AI context provider serialized.
JsonElement serializedThread = thread.Serialize();
// Let's print it to console to show the contents.
Console.WriteLine(JsonSerializer.Serialize(serializedThread, options: new JsonSerializerOptions() { WriteIndented = true, IndentSize = 2 }) + "\n");
// The serialized thread can be stored long term in a persistent store, but in this case we will just deserialize again and continue the conversation.
thread = await agent.DeserializeThreadAsync(serializedThread);
Console.WriteLine(await agent.RunAsync("Considering my appointments, can you create a plan for my day that plans out when I should complete the items on my todo list?", thread) + "\n");
namespace SampleApp
{
/// <summary>
/// An <see cref="AIContextProvider"/>, which maintains a todo list for the agent.
/// </summary>
internal sealed class TodoListAIContextProvider : AIContextProvider
{
private readonly List<string> _todoItems = new();
public TodoListAIContextProvider(JsonElement jsonElement, JsonSerializerOptions? jsonSerializerOptions = null)
{
// Only try and restore the state if we got an array, since any other json would be invalid or undefined/null meaning
// it's the first time we are running.
if (jsonElement.ValueKind == JsonValueKind.Array)
{
this._todoItems = JsonSerializer.Deserialize<List<string>>(jsonElement.GetRawText(), jsonSerializerOptions) ?? new List<string>();
}
}
public override ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
StringBuilder outputMessageBuilder = new();
outputMessageBuilder.AppendLine("Your todo list contains the following items:");
if (this._todoItems.Count == 0)
{
outputMessageBuilder.AppendLine(" (no items)");
}
else
{
for (int i = 0; i < this._todoItems.Count; i++)
{
outputMessageBuilder.AppendLine($"{i}. {this._todoItems[i]}");
}
}
return new ValueTask<AIContext>(new AIContext
{
Tools = [AIFunctionFactory.Create(this.AddTodoItem), AIFunctionFactory.Create(this.RemoveTodoItem)],
Messages = [new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString())]
});
}
[Description("Adds an item to the todo list. Index is zero based.")]
private void RemoveTodoItem(int index) =>
this._todoItems.RemoveAt(index);
private void AddTodoItem(string item) =>
this._todoItems.Add(string.IsNullOrWhiteSpace(item) ? throw new ArgumentException("Item must have a value") : item);
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null) =>
JsonSerializer.SerializeToElement(this._todoItems, jsonSerializerOptions);
}
/// <summary>
/// An <see cref="AIContextProvider"/> which searches for upcoming calendar events and adds them to the AI context.
/// </summary>
internal sealed class CalendarSearchAIContextProvider(Func<Task<string[]>> loadNextThreeCalendarEvents) : AIContextProvider
{
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
var events = await loadNextThreeCalendarEvents();
StringBuilder outputMessageBuilder = new();
outputMessageBuilder.AppendLine("You have the following upcoming calendar events:");
foreach (var calendarEvent in events)
{
outputMessageBuilder.AppendLine($" - {calendarEvent}");
}
return new()
{
Messages =
[
new MEAI.ChatMessage(ChatRole.User, outputMessageBuilder.ToString()),
]
};
}
}
/// <summary>
/// An <see cref="AIContextProvider"/> which aggregates multiple AI context providers into one.
/// Serialized state for the different providers are stored under their type name.
/// Tools and messages from all providers are combined, and instructions are concatenated.
/// </summary>
internal sealed class AggregatingAIContextProvider : AIContextProvider
{
private readonly List<AIContextProvider> _providers = new();
public AggregatingAIContextProvider(ProviderFactory[] providerFactories, JsonElement jsonElement, JsonSerializerOptions? jsonSerializerOptions)
{
// We received a json object, so let's check if it has some previously serialized state that we can use.
if (jsonElement.ValueKind == JsonValueKind.Object)
{
this._providers = providerFactories
.Select(factory => factory.FactoryMethod(jsonElement.TryGetProperty(factory.ProviderType.Name, out var prop) ? prop : default, jsonSerializerOptions))
.ToList();
return;
}
// We didn't receive any valid json, so we can just construct fresh providers.
this._providers = providerFactories
.Select(factory => factory.FactoryMethod(default, jsonSerializerOptions))
.ToList();
}
public override async ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default)
{
// Invoke all the sub providers.
var tasks = this._providers.Select(provider => provider.InvokingAsync(context, cancellationToken).AsTask());
var results = await Task.WhenAll(tasks);
// Combine the results from each sub provider.
return new AIContext
{
Tools = results.SelectMany(r => r.Tools ?? []).ToList(),
Messages = results.SelectMany(r => r.Messages ?? []).ToList(),
Instructions = string.Join("\n", results.Select(r => r.Instructions).Where(s => !string.IsNullOrEmpty(s)))
};
}
public override JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
{
Dictionary<string, JsonElement> elements = new();
foreach (var provider in this._providers)
{
JsonElement element = provider.Serialize(jsonSerializerOptions);
// Don't try to store state for any providers that aren't producing any.
if (element.ValueKind != JsonValueKind.Undefined && element.ValueKind != JsonValueKind.Null)
{
elements[provider.GetType().Name] = element;
}
}
return JsonSerializer.SerializeToElement(elements, jsonSerializerOptions);
}
public static ProviderFactory CreateFactory<TProviderType>(Func<JsonElement, JsonSerializerOptions?, TProviderType> factoryMethod)
where TProviderType : AIContextProvider => new()
{
FactoryMethod = (jsonElement, jsonSerializerOptions) => factoryMethod(jsonElement, jsonSerializerOptions),
ProviderType = typeof(TProviderType)
};
public readonly struct ProviderFactory
{
public Func<JsonElement, JsonSerializerOptions?, AIContextProvider> FactoryMethod { get; init; }
public Type ProviderType { get; init; }
}
}
}
@@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites:
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|[Deep research with an agent](./Agent_Step18_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|[Declarative agent](./Agent_Step19_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step20_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
## Running the samples from the console
@@ -28,14 +28,14 @@ AgentVersion createdAgentVersion = aiProjectClient.Agents.CreateAgentVersion(age
// agentVersion.Version = <versionNumber>,
// agentVersion.Name = <agentName>
// You can retrieve an AIAgent for an already created server side agent version.
AIAgent existingJokerAgent = aiProjectClient.GetAIAgent(createdAgentVersion);
// You can use an AIAgent with an already created server side agent version.
AIAgent existingJokerAgent = aiProjectClient.AsAIAgent(createdAgentVersion);
// You can also create another AIAgent version by providing the same name with a different definition/instruction.
AIAgent newJokerAgent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
AIAgent newJokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: "You are extremely hilarious at telling jokes.");
// You can also get the AIAgent latest version by just providing its name.
AIAgent jokerAgentLatest = aiProjectClient.GetAIAgent(name: JokerName);
AIAgent jokerAgentLatest = await aiProjectClient.GetAIAgentAsync(name: JokerName);
AgentVersion latestAgentVersion = jokerAgentLatest.GetService<AgentVersion>()!;
// The AIAgent version can be accessed via the GetService method.
@@ -23,8 +23,8 @@ AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deplo
// You can create a server side agent version with the Azure.AI.Agents SDK client below.
AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
// You can retrieve an AIAgent for a already created server side agent version.
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
// You can use an AIAgent with an already created server side agent version.
AIAgent jokerAgent = aiProjectClient.AsAIAgent(agentVersion);
// Invoke the agent with streaming support.
await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate."))
@@ -19,19 +19,23 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
// Define the agent you want to create. (Prompt Agent in this case)
AgentVersionCreationOptions options = new(new PromptAgentDefinition(model: deploymentName) { Instructions = JokerInstructions });
// Create a server side agent version with the Azure.AI.Agents SDK client.
AgentVersion agentVersion = aiProjectClient.Agents.CreateAgentVersion(agentName: JokerName, options);
// Retrieve an AIAgent for the created server side agent version.
AIAgent jokerAgent = aiProjectClient.GetAIAgent(agentVersion);
ChatClientAgent jokerAgent = await aiProjectClient.CreateAIAgentAsync(name: JokerName, options);
// Invoke the agent with a multi-turn conversation, where the context is preserved in the thread object.
AgentThread thread = await jokerAgent.GetNewThreadAsync();
// Create a conversation in the server
ProjectConversationsClient conversationsClient = aiProjectClient.GetProjectOpenAIClient().GetProjectConversationsClient();
ProjectConversation conversation = await conversationsClient.CreateProjectConversationAsync();
// Providing the conversation Id is not strictly necessary, but by not providing it no information will show up in the Foundry Project UI as conversations.
// Threads that doesn't have a conversation Id will work based on the `PreviousResponseId`.
AgentThread thread = await jokerAgent.GetNewThreadAsync(conversation.Id);
Console.WriteLine(await jokerAgent.RunAsync("Tell me a joke about a pirate.", thread));
Console.WriteLine(await jokerAgent.RunAsync("Now add some emojis to the joke and tell it in the voice of a pirate's parrot.", thread));
// Invoke the agent with a multi-turn conversation and streaming, where the context is preserved in the thread object.
thread = await jokerAgent.GetNewThreadAsync();
thread = await jokerAgent.GetNewThreadAsync(conversation.Id);
await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Tell me a joke about a pirate.", thread))
{
Console.WriteLine(update);
@@ -43,3 +47,6 @@ await foreach (AgentResponseUpdate update in jokerAgent.RunStreamingAsync("Now a
// Cleanup by agent name removes the agent version created.
await aiProjectClient.Agents.DeleteAgentAsync(jokerAgent.Name);
// Cleanup the conversation created.
await conversationsClient.DeleteConversationAsync(conversation.Id);
@@ -1,14 +1,15 @@
# Multi-turn Conversation with AI Agents
This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads.
This sample demonstrates how to implement multi-turn conversations with AI agents, where context is preserved across multiple agent runs using threads and conversation IDs.
## What this sample demonstrates
- Creating an AI agent with instructions
- Using threads to maintain conversation context
- Creating a project conversation to track conversations in the Foundry UI
- Using threads with conversation IDs to maintain conversation context
- Running multi-turn conversations with text output
- Running multi-turn conversations with streaming output
- Managing agent lifecycle (creation and deletion)
- Managing agent and conversation lifecycle (creation and deletion)
## Prerequisites
@@ -41,10 +42,18 @@ dotnet run --project .\FoundryAgents_Step02_MultiturnConversation
The sample will:
1. Create an agent named "JokerAgent" with instructions to tell jokes
2. Create a thread for conversation context
3. Run the agent with a text prompt and display the response
4. Send a follow-up message to the same thread, demonstrating context preservation
5. Create a new thread and run the agent with streaming
6. Send a follow-up streaming message to demonstrate multi-turn streaming
7. Clean up resources by deleting the agent
2. Create a project conversation to enable visibility in the Azure Foundry UI
3. Create a thread linked to the conversation ID for context tracking
4. Run the agent with a text prompt and display the response
5. Send a follow-up message to the same thread, demonstrating context preservation
6. Create a new thread sharing the same conversation ID and run the agent with streaming
7. Send a follow-up streaming message to demonstrate multi-turn streaming
8. Clean up resources by deleting the agent and conversation
## Conversation ID vs PreviousResponseId
When working with multi-turn conversations, there are two approaches:
- **With Conversation ID**: By passing a `conversation.Id` to `GetNewThreadAsync()`, the conversation will be visible in the Azure Foundry Project UI. This is useful for tracking and debugging conversations.
- **Without Conversation ID**: Threads created without a conversation ID still work correctly, maintaining context via `PreviousResponseId`. However, these conversations may not appear in the Foundry UI.
@@ -44,7 +44,7 @@ Console.WriteLine($"Age: {response.Result.Age}");
Console.WriteLine($"Occupation: {response.Result.Occupation}");
// Create the ChatClientAgent with the specified name, instructions, and expected structured output the agent should produce.
ChatClientAgent agentWithPersonInfo = aiProjectClient.CreateAIAgent(
ChatClientAgent agentWithPersonInfo = await aiProjectClient.CreateAIAgentAsync(
model: deploymentName,
new ChatClientAgentOptions()
{
@@ -32,7 +32,7 @@ using var tracerProvider = tracerProviderBuilder.Build();
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Define the agent you want to create. (Prompt Agent in this case)
AIAgent agent = aiProjectClient.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions)
AIAgent agent = (await aiProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions))
.AsBuilder()
.UseOpenTelemetry(sourceName: sourceName)
.Build();
@@ -2,6 +2,7 @@
// This sample shows how to use dependency injection to register an AIAgent and use it from a hosted service with a user input chat loop.
using System.ClientModel;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
@@ -14,16 +15,27 @@ string deploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJEC
const string JokerInstructions = "You are good at telling jokes.";
const string JokerName = "JokerAgent";
AIProjectClient aIProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Create a new agent if one doesn't exist already.
ChatClientAgent agent;
try
{
agent = await aIProjectClient.GetAIAgentAsync(name: JokerName);
}
catch (ClientResultException ex) when (ex.Status == 404)
{
agent = await aIProjectClient.CreateAIAgentAsync(name: JokerName, model: deploymentName, instructions: JokerInstructions);
}
// Create a host builder that we will register services with and then run.
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
// Add the agents client to the service collection.
builder.Services.AddSingleton((sp) => new AIProjectClient(new Uri(endpoint), new AzureCliCredential()));
builder.Services.AddSingleton((sp) => aIProjectClient);
// Add the AI agent to the service collection.
builder.Services.AddSingleton<AIAgent>((sp)
=> sp.GetRequiredService<AIProjectClient>()
.CreateAIAgent(name: JokerName, model: deploymentName, instructions: JokerInstructions));
builder.Services.AddSingleton<AIAgent>((sp) => agent);
// Add a sample service that will use the agent to respond to user input.
builder.Services.AddHostedService<SampleService>();
@@ -30,7 +30,7 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
Console.WriteLine($"Creating the agent '{agentName}' ...");
// Define the agent you want to create. (Prompt Agent in this case)
AIAgent agent = aiProjectClient.CreateAIAgent(
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
name: agentName,
model: deploymentName,
instructions: "You answer questions related to GitHub repositories only.",
@@ -17,7 +17,7 @@ const string VisionName = "VisionAgent";
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
// Define the agent you want to create. (Prompt Agent in this case)
AIAgent agent = aiProjectClient.CreateAIAgent(name: VisionName, model: deploymentName, instructions: VisionInstructions);
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model: deploymentName, instructions: VisionInstructions);
ChatMessage message = new(ChatRole.User, [
new TextContent("What do you see in this image?"),
@@ -25,14 +25,14 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
// Create the weather agent with function tools.
AITool weatherTool = AIFunctionFactory.Create(GetWeather);
AIAgent weatherAgent = aiProjectClient.CreateAIAgent(
AIAgent weatherAgent = await aiProjectClient.CreateAIAgentAsync(
name: WeatherName,
model: deploymentName,
instructions: WeatherInstructions,
tools: [weatherTool]);
// Create the main agent, and provide the weather agent as a function tool.
AIAgent agent = aiProjectClient.CreateAIAgent(
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
name: MainName,
model: deploymentName,
instructions: MainInstructions,
@@ -34,7 +34,7 @@ AITool dateTimeTool = AIFunctionFactory.Create(GetDateTime, name: nameof(GetDate
AITool getWeatherTool = AIFunctionFactory.Create(GetWeather, name: nameof(GetWeather));
// Define the agent you want to create. (Prompt Agent in this case)
AIAgent originalAgent = aiProjectClient.CreateAIAgent(
AIAgent originalAgent = await aiProjectClient.CreateAIAgentAsync(
name: AssistantName,
model: deploymentName,
instructions: AssistantInstructions,
@@ -69,7 +69,7 @@ Console.WriteLine($"Function calling response: {functionCallResponse}");
// Special per-request middleware agent.
Console.WriteLine("\n\n=== Example 4: Middleware with human in the loop function approval ===");
AIAgent humanInTheLoopAgent = aiProjectClient.CreateAIAgent(
AIAgent humanInTheLoopAgent = await aiProjectClient.CreateAIAgentAsync(
name: "HumanInTheLoopAgent",
model: deploymentName,
instructions: "You are an Human in the loop testing AI assistant that helps people find information.",
@@ -34,7 +34,7 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential(
// Define the agent with plugin tools
// Define the agent you want to create. (Prompt Agent in this case)
AIAgent agent = aiProjectClient.CreateAIAgent(
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
name: AssistantName,
model: deploymentName,
instructions: AssistantInstructions,
@@ -15,6 +15,17 @@ For more information about the previous classic agents and for what's new in Fou
For a sample demonstrating how to use classic Foundry Agents, see the following: [Agent with Azure AI Persistent](../AgentProviders/Agent_With_AzureAIAgentsPersistent/README.md).
## Agent Versioning and Static Definitions
One of the key architectural changes in the new Foundry Agents compared to the classic experience is how agent definitions are handled. In the new architecture, agents have **versions** and their definitions are established at creation time. This means that the agent's configuration—including instructions, tools, and options—is fixed when the agent version is created.
> [!IMPORTANT]
> Agent versions are static and strictly adhere to their original definition. Any attempt to provide or override tools, instructions, or options during an agent run or request will be ignored by the agent, as the API does not support runtime configuration changes. All agent behavior must be defined at agent creation time.
This design ensures consistency and predictability in agent behavior across all interactions with a specific agent version.
The Agent Framework intentionally ignores unsupported runtime parameters rather than throwing exceptions. This abstraction-first approach ensures that code written against the unified agent abstraction remains portable across providers (OpenAI, Azure OpenAI, Foundry Agents). It removes the need for provider-specific conditional logic. Teams can adopt Foundry Agents without rewriting existing orchestration code. Configurations that work with other providers will gracefully degrade, rather than fail, when the underlying API does not support them.
## Getting started with Foundry Agents prerequisites
Before you begin, ensure you have the following prerequisites:
@@ -45,7 +45,7 @@ internal sealed class Program
string workflowInput = GetWorkflowInput(args);
AIAgent agent = aiProjectClient.GetAIAgent(agentVersion);
AIAgent agent = aiProjectClient.AsAIAgent(agentVersion);
AgentThread thread = await agent.GetNewThreadAsync();
@@ -19,9 +19,13 @@ namespace Microsoft.Agents.AI;
/// and process user requests. An agent instance may participate in multiple concurrent conversations, and each conversation
/// may involve multiple agents working together.
/// </remarks>
[DebuggerDisplay("{DisplayName,nq}")]
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public abstract class AIAgent
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private string DebuggerDisplay =>
this.Name is { } name ? $"Id = {this.Id}, Name = {name}" : $"Id = {this.Id}";
/// <summary>
/// Gets the unique identifier for this agent instance.
/// </summary>
@@ -82,39 +82,6 @@ public static class PersistentAgentsClientExtensions
}, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <returns>A <see cref="ChatClientAgent"/> for the persistent agent.</returns>
/// <param name="agentId"> The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
public static ChatClientAgent GetAIAgent(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken);
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, chatOptions, clientFactory, services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
@@ -232,45 +199,6 @@ public static class PersistentAgentsClientExtensions
return new ChatClientAgent(chatClient, agentOptions, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the persistent agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="agentId"/> is empty or whitespace.</exception>
public static ChatClientAgent GetAIAgent(
this PersistentAgentsClient persistentAgentsClient,
string agentId,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var persistentAgentResponse = persistentAgentsClient.Administration.GetAgent(agentId, cancellationToken);
return persistentAgentsClient.AsAIAgent(persistentAgentResponse, options, clientFactory, services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
@@ -366,122 +294,6 @@ public static class PersistentAgentsClientExtensions
return await persistentAgentsClient.GetAIAgentAsync(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
/// <param name="model">The model to be used by the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="instructions">The instructions for the agent.</param>
/// <param name="tools">The tools to be used by the agent.</param>
/// <param name="toolResources">The resources for the tools.</param>
/// <param name="temperature">The temperature setting for the agent.</param>
/// <param name="topP">The top-p setting for the agent.</param>
/// <param name="responseFormat">The response format for the agent.</param>
/// <param name="metadata">The metadata for the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
public static ChatClientAgent CreateAIAgent(
this PersistentAgentsClient persistentAgentsClient,
string model,
string? name = null,
string? description = null,
string? instructions = null,
IEnumerable<ToolDefinition>? tools = null,
ToolResources? toolResources = null,
float? temperature = null,
float? topP = null,
BinaryData? responseFormat = null,
IReadOnlyDictionary<string, string>? metadata = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent(
model: model,
name: name,
description: description,
instructions: instructions,
tools: tools,
toolResources: toolResources,
temperature: temperature,
topP: topP,
responseFormat: responseFormat,
metadata: metadata,
cancellationToken: cancellationToken);
// Get a local proxy for the agent to work with.
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken);
}
/// <summary>
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
/// <param name="persistentAgentsClient">The <see cref="PersistentAgentsClient"/> to create the agent with.</param>
/// <param name="model">The model to be used by the agent.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="persistentAgentsClient"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
public static ChatClientAgent CreateAIAgent(
this PersistentAgentsClient persistentAgentsClient,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (persistentAgentsClient is null)
{
throw new ArgumentNullException(nameof(persistentAgentsClient));
}
if (string.IsNullOrWhiteSpace(model))
{
throw new ArgumentException($"{nameof(model)} should not be null or whitespace.", nameof(model));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools);
var createPersistentAgentResponse = persistentAgentsClient.Administration.CreateAgent(
model: model,
name: options.Name,
description: options.Description,
instructions: options.ChatOptions?.Instructions,
tools: toolDefinitionsAndResources.ToolDefinitions,
toolResources: toolDefinitionsAndResources.ToolResources,
temperature: null,
topP: null,
responseFormat: null,
metadata: null,
cancellationToken: cancellationToken);
if (options.ChatOptions?.Tools is { Count: > 0 } && (toolDefinitionsAndResources.FunctionToolsAndOtherTools is null || options.ChatOptions.Tools.Count != toolDefinitionsAndResources.FunctionToolsAndOtherTools.Count))
{
options = options.Clone();
options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools;
}
// Get a local proxy for the agent to work with.
return persistentAgentsClient.GetAIAgent(createPersistentAgentResponse.Value.Id, options, clientFactory: clientFactory, services: services, cancellationToken: cancellationToken);
}
/// <summary>
/// Creates a new server side agent using the provided <see cref="PersistentAgentsClient"/>.
/// </summary>
@@ -27,7 +27,7 @@ namespace Azure.AI.Projects;
public static partial class AzureAIProjectChatClientExtensions
{
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentReference"/>.
/// </summary>
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to create the <see cref="ChatClientAgent"/> with. Cannot be <see langword="null"/>.</param>
/// <param name="agentReference">The <see cref="AgentReference"/> representing the name and version of the server side agent to create a <see cref="ChatClientAgent"/> for. Cannot be <see langword="null"/>.</param>
@@ -38,10 +38,10 @@ public static partial class AzureAIProjectChatClientExtensions
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="agentReference"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
/// <remarks>
/// When retrieving an agent by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
/// When instantiating a <see cref="ChatClientAgent"/> by using an <see cref="AgentReference"/>, minimal information will be available about the agent in the instance level, and any logic that relies
/// on <see cref="AIAgent.GetService(Type, object?)"/> to retrieve information about the agent like <see cref="AgentVersion" /> will receive <see langword="null"/> as the result.
/// </remarks>
public static ChatClientAgent GetAIAgent(
public static ChatClientAgent AsAIAgent(
this AIProjectClient aiProjectClient,
AgentReference agentReference,
IList<AITool>? tools = null,
@@ -52,7 +52,7 @@ public static partial class AzureAIProjectChatClientExtensions
Throw.IfNull(agentReference);
ThrowIfInvalidAgentName(agentReference.Name);
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentReference,
new ChatClientAgentOptions()
@@ -65,40 +65,6 @@ public static partial class AzureAIProjectChatClientExtensions
services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
/// </summary>
/// <param name="aiProjectClient">The <see cref="AIProjectClient"/> to create the <see cref="ChatClientAgent"/> with. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name of the server side agent to create a <see cref="ChatClientAgent"/> for. Cannot be <see langword="null"/> or whitespace.</param>
/// <param name="tools">The tools to use when interacting with the agent. This is required when using prompt agent definitions with tools.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations based on the latest version of the named Azure AI Agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="name"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="name"/> is empty or whitespace, or when the agent with the specified name was not found.</exception>
/// <exception cref="InvalidOperationException">The agent with the specified name was not found.</exception>
public static ChatClientAgent GetAIAgent(
this AIProjectClient aiProjectClient,
string name,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(aiProjectClient);
ThrowIfInvalidAgentName(name);
AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, name, cancellationToken);
return AsAIAgent(
aiProjectClient,
agentRecord,
tools,
clientFactory,
services);
}
/// <summary>
/// Asynchronously retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
/// </summary>
@@ -134,7 +100,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Gets a runnable agent instance from the provided agent record.
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentRecord"/>.
/// </summary>
/// <param name="aiProjectClient">The client used to interact with Azure AI Agents. Cannot be <see langword="null"/>.</param>
/// <param name="agentRecord">The agent record to be converted. The latest version will be used. Cannot be <see langword="null"/>.</param>
@@ -155,7 +121,7 @@ public static partial class AzureAIProjectChatClientExtensions
var allowDeclarativeMode = tools is not { Count: > 0 };
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentRecord,
tools,
@@ -165,7 +131,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Gets a runnable agent instance from a <see cref="AgentVersion"/> containing metadata about an Azure AI Agent.
/// Uses an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/> and <see cref="AgentVersion"/>.
/// </summary>
/// <param name="aiProjectClient">The client used to interact with Azure AI Agents. Cannot be <see langword="null"/>.</param>
/// <param name="agentVersion">The agent version to be converted. Cannot be <see langword="null"/>.</param>
@@ -186,7 +152,7 @@ public static partial class AzureAIProjectChatClientExtensions
var allowDeclarativeMode = tools is not { Count: > 0 };
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentVersion,
tools,
@@ -196,47 +162,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to cancel the operation if needed.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static ChatClientAgent GetAIAgent(
this AIProjectClient aiProjectClient,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(options);
if (string.IsNullOrWhiteSpace(options.Name))
{
throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options));
}
ThrowIfInvalidAgentName(options.Name);
AgentRecord agentRecord = GetAgentRecordByName(aiProjectClient, options.Name, cancellationToken);
var agentVersion = agentRecord.Versions.Latest;
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true);
return CreateChatClientAgent(
aiProjectClient,
agentVersion,
agentOptions,
clientFactory,
services);
}
/// <summary>
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
/// Asynchronously retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AIProjectClient"/>.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
@@ -267,7 +193,7 @@ public static partial class AzureAIProjectChatClientExtensions
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: true);
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentVersion,
agentOptions,
@@ -276,49 +202,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Creates a new Prompt AI agent using the specified configuration parameters.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name for the agent.</param>
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
/// <param name="instructions">The instructions that guide the agent's behavior. Cannot be <see langword="null"/> or whitespace.</param>
/// <param name="description">The description for the agent.</param>
/// <param name="tools">The tools to use when interacting with the agent, this is required when using prompt agent definitions with tools.</param>
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/>, <paramref name="model"/>, or <paramref name="instructions"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> or <paramref name="instructions"/> is empty or whitespace.</exception>
/// <remarks>When using prompt agent definitions with tools the parameter <paramref name="tools"/> needs to be provided.</remarks>
public static ChatClientAgent CreateAIAgent(
this AIProjectClient aiProjectClient,
string name,
string model,
string instructions,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(aiProjectClient);
ThrowIfInvalidAgentName(name);
Throw.IfNullOrWhitespace(model);
Throw.IfNullOrWhitespace(instructions);
return CreateAIAgent(
aiProjectClient,
name,
tools,
new AgentVersionCreationOptions(new PromptAgentDefinition(model) { Instructions = instructions }) { Description = description },
clientFactory,
services,
cancellationToken);
}
/// <summary>
/// Creates a new Prompt AI agent using the specified configuration parameters.
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name for the agent.</param>
@@ -360,73 +244,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
/// <param name="options">The options for creating the agent. Cannot be <see langword="null"/>.</param>
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> to cancel the operation if needed.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace, or when the agent name is not provided in the options.</exception>
public static ChatClientAgent CreateAIAgent(
this AIProjectClient aiProjectClient,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(aiProjectClient);
Throw.IfNull(options);
Throw.IfNullOrWhitespace(model);
const bool RequireInvocableTools = true;
if (string.IsNullOrWhiteSpace(options.Name))
{
throw new ArgumentException("Agent name must be provided in the options.Name property", nameof(options));
}
ThrowIfInvalidAgentName(options.Name);
PromptAgentDefinition agentDefinition = new(model)
{
Instructions = options.ChatOptions?.Instructions,
Temperature = options.ChatOptions?.Temperature,
TopP = options.ChatOptions?.TopP,
TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) }
};
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
ApplyToolsToAgentDefinition(agentDefinition, options.ChatOptions?.Tools);
AgentVersionCreationOptions? creationOptions = new(agentDefinition);
if (!string.IsNullOrWhiteSpace(options.Description))
{
creationOptions.Description = options.Description;
}
AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, options.Name, creationOptions, cancellationToken);
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools);
return CreateChatClientAgent(
aiProjectClient,
agentVersion,
agentOptions,
clientFactory,
services);
}
/// <summary>
/// Creates a new Prompt AI Agent using the provided <see cref="AIProjectClient"/> and options.
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="model">The name of the model to use for the agent. Cannot be <see langword="null"/> or whitespace.</param>
@@ -483,7 +301,7 @@ public static partial class AzureAIProjectChatClientExtensions
var agentOptions = CreateChatClientAgentOptions(agentVersion, options, RequireInvocableTools);
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentVersion,
agentOptions,
@@ -492,42 +310,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>
/// Creates a new AI agent using the specified agent definition and optional configuration parameters.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
/// <param name="name">The name for the agent.</param>
/// <param name="creationOptions">Settings that control the creation of the agent.</param>
/// <param name="clientFactory">A factory function to customize the creation of the chat client used by the agent.</param>
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the newly created agent.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="aiProjectClient"/> or <paramref name="creationOptions"/> is <see langword="null"/>.</exception>
/// <remarks>
/// When using this extension method with a <see cref="PromptAgentDefinition"/> the tools are only declarative and not invocable.
/// Invocation of any in-process tools will need to be handled manually.
/// </remarks>
public static ChatClientAgent CreateAIAgent(
this AIProjectClient aiProjectClient,
string name,
AgentVersionCreationOptions creationOptions,
Func<IChatClient, IChatClient>? clientFactory = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(aiProjectClient);
ThrowIfInvalidAgentName(name);
Throw.IfNull(creationOptions);
return CreateAIAgent(
aiProjectClient,
name,
tools: null,
creationOptions,
clientFactory,
services: null,
cancellationToken);
}
/// <summary>
/// Asynchronously creates a new AI agent using the specified agent definition and optional configuration
/// Creates a new Prompt AI agent in the Foundry service using the specified configuration parameters, and exposes it as a <see cref="ChatClientAgent"/>.
/// parameters.
/// </summary>
/// <param name="aiProjectClient">The client used to manage and interact with AI agents. Cannot be <see langword="null"/>.</param>
@@ -566,18 +349,6 @@ public static partial class AzureAIProjectChatClientExtensions
private static readonly ModelReaderWriterOptions s_modelWriterOptionsWire = new("W");
/// <summary>
/// Retrieves an agent record by name using the Protocol method with user-agent header.
/// </summary>
private static AgentRecord GetAgentRecordByName(AIProjectClient aiProjectClient, string agentName, CancellationToken cancellationToken)
{
ClientResult protocolResponse = aiProjectClient.Agents.GetAgent(agentName, cancellationToken.ToRequestOptions(false));
var rawResponse = protocolResponse.GetRawResponse();
AgentRecord? result = ModelReaderWriter.Read<AgentRecord>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return ClientResult.FromOptionalValue(result, rawResponse).Value!
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
}
/// <summary>
/// Asynchronously retrieves an agent record by name using the Protocol method with user-agent header.
/// </summary>
@@ -590,19 +361,6 @@ public static partial class AzureAIProjectChatClientExtensions
?? throw new InvalidOperationException($"Agent with name '{agentName}' not found.");
}
/// <summary>
/// Creates an agent version using the Protocol method with user-agent header.
/// </summary>
private static AgentVersion CreateAgentVersionWithProtocol(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken)
{
using BinaryContent protocolRequest = BinaryContent.Create(ModelReaderWriter.Write(creationOptions, ModelReaderWriterOptions.Json, AzureAIProjectsContext.Default));
ClientResult protocolResponse = aiProjectClient.Agents.CreateAgentVersion(agentName, protocolRequest, cancellationToken.ToRequestOptions(false));
var rawResponse = protocolResponse.GetRawResponse();
AgentVersion? result = ModelReaderWriter.Read<AgentVersion>(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default);
return ClientResult.FromValue(result, rawResponse).Value!;
}
/// <summary>
/// Asynchronously creates an agent version using the Protocol method with user-agent header.
/// </summary>
@@ -616,33 +374,6 @@ public static partial class AzureAIProjectChatClientExtensions
return ClientResult.FromValue(result, rawResponse).Value!;
}
private static ChatClientAgent CreateAIAgent(
this AIProjectClient aiProjectClient,
string name,
IList<AITool>? tools,
AgentVersionCreationOptions creationOptions,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services,
CancellationToken cancellationToken)
{
var allowDeclarativeMode = tools is not { Count: > 0 };
if (!allowDeclarativeMode)
{
ApplyToolsToAgentDefinition(creationOptions.Definition, tools);
}
AgentVersion agentVersion = CreateAgentVersionWithProtocol(aiProjectClient, name, creationOptions, cancellationToken);
return CreateChatClientAgent(
aiProjectClient,
agentVersion,
tools,
clientFactory,
!allowDeclarativeMode,
services);
}
private static async Task<ChatClientAgent> CreateAIAgentAsync(
this AIProjectClient aiProjectClient,
string name,
@@ -661,7 +392,7 @@ public static partial class AzureAIProjectChatClientExtensions
AgentVersion agentVersion = await CreateAgentVersionWithProtocolAsync(aiProjectClient, name, creationOptions, cancellationToken).ConfigureAwait(false);
return CreateChatClientAgent(
return AsChatClientAgent(
aiProjectClient,
agentVersion,
tools,
@@ -671,7 +402,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
private static ChatClientAgent CreateChatClientAgent(
private static ChatClientAgent AsChatClientAgent(
AIProjectClient aiProjectClient,
AgentVersion agentVersion,
ChatClientAgentOptions agentOptions,
@@ -689,7 +420,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
private static ChatClientAgent CreateChatClientAgent(
private static ChatClientAgent AsChatClientAgent(
AIProjectClient aiProjectClient,
AgentRecord agentRecord,
ChatClientAgentOptions agentOptions,
@@ -707,7 +438,7 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>This method creates an <see cref="ChatClientAgent"/> with the specified ChatClientAgentOptions.</summary>
private static ChatClientAgent CreateChatClientAgent(
private static ChatClientAgent AsChatClientAgent(
AIProjectClient aiProjectClient,
AgentReference agentReference,
ChatClientAgentOptions agentOptions,
@@ -725,14 +456,14 @@ public static partial class AzureAIProjectChatClientExtensions
}
/// <summary>This method creates an <see cref="ChatClientAgent"/> with a auto-generated ChatClientAgentOptions from the specified configuration parameters.</summary>
private static ChatClientAgent CreateChatClientAgent(
private static ChatClientAgent AsChatClientAgent(
AIProjectClient AIProjectClient,
AgentVersion agentVersion,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
bool requireInvocableTools,
IServiceProvider? services)
=> CreateChatClientAgent(
=> AsChatClientAgent(
AIProjectClient,
agentVersion,
CreateChatClientAgentOptions(agentVersion, new ChatOptions() { Tools = tools }, requireInvocableTools),
@@ -740,14 +471,14 @@ public static partial class AzureAIProjectChatClientExtensions
services);
/// <summary>This method creates an <see cref="ChatClientAgent"/> with a auto-generated ChatClientAgentOptions from the specified configuration parameters.</summary>
private static ChatClientAgent CreateChatClientAgent(
private static ChatClientAgent AsChatClientAgent(
AIProjectClient AIProjectClient,
AgentRecord agentRecord,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
bool requireInvocableTools,
IServiceProvider? services)
=> CreateChatClientAgent(
=> AsChatClientAgent(
AIProjectClient,
agentRecord,
CreateChatClientAgentOptions(agentRecord.Versions.Latest, new ChatOptions() { Tools = tools }, requireInvocableTools),
@@ -26,13 +26,6 @@ public readonly struct AgentSessionId : IEquatable<AgentSessionId>
this._entityId = new EntityInstanceId(ToEntityName(name), key);
}
/// <summary>
/// Converts an agent name to its underlying entity name representation.
/// </summary>
/// <param name="name">The agent name.</param>
/// <returns>The entity name used by Durable Task for this agent.</returns>
public static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
/// <summary>
/// Gets the name of the agent that owns the session. Names are case-insensitive.
/// </summary>
@@ -43,6 +36,17 @@ public readonly struct AgentSessionId : IEquatable<AgentSessionId>
/// </summary>
public string Key => this._entityId.Key;
/// <summary>
/// Converts an agent name to its underlying entity name representation.
/// </summary>
/// <param name="name">The agent name.</param>
/// <returns>The entity name used by Durable Task for this agent.</returns>
internal static string ToEntityName(string name) => $"{EntityNamePrefix}{name}";
/// <summary>
/// Converts the <see cref="AgentSessionId"/> to an <see cref="EntityInstanceId"/>.
/// </summary>
/// <returns>The <see cref="EntityInstanceId"/> representation of the <see cref="AgentSessionId"/>.</returns>
internal EntityInstanceId ToEntityId() => this._entityId;
/// <summary>
@@ -93,39 +93,6 @@ public static class OpenAIAssistantClientExtensions
}, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
/// </summary>
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="chatOptions">Options that should apply to all runs of the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
public static ChatClientAgent GetAIAgent(
this AssistantClient assistantClient,
string agentId,
ChatOptions? chatOptions = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (assistantClient is null)
{
throw new ArgumentNullException(nameof(assistantClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
var assistant = assistantClient.GetAssistant(agentId, cancellationToken);
return assistantClient.AsAIAgent(assistant, chatOptions, clientFactory, services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
/// </summary>
@@ -245,46 +212,6 @@ public static class OpenAIAssistantClientExtensions
return new ChatClientAgent(chatClient, mergedOptions, services: services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
/// </summary>
/// <param name="assistantClient">The <see cref="AssistantClient"/> to create the <see cref="ChatClientAgent"/> with.</param>
/// <param name="agentId">The ID of the server side agent to create a <see cref="ChatClientAgent"/> for.</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="ChatClientAgent"/> instance that can be used to perform operations on the assistant agent.</returns>
/// <exception cref="ArgumentNullException"><paramref name="assistantClient"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="agentId"/> is empty or whitespace.</exception>
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
public static ChatClientAgent GetAIAgent(
this AssistantClient assistantClient,
string agentId,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null,
CancellationToken cancellationToken = default)
{
if (assistantClient is null)
{
throw new ArgumentNullException(nameof(assistantClient));
}
if (string.IsNullOrWhiteSpace(agentId))
{
throw new ArgumentException($"{nameof(agentId)} should not be null or whitespace.", nameof(agentId));
}
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
var assistant = assistantClient.GetAssistant(agentId, cancellationToken);
return assistantClient.AsAIAgent(assistant, options, clientFactory, services);
}
/// <summary>
/// Retrieves an existing server side agent, wrapped as a <see cref="ChatClientAgent"/> using the provided <see cref="AssistantClient"/>.
/// </summary>
@@ -325,111 +252,6 @@ public static class OpenAIAssistantClientExtensions
return assistantClient.AsAIAgent(assistantResponse, options, clientFactory, services);
}
/// <summary>
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
/// </summary>
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
/// <param name="instructions">Optional system instructions that define the agent's behavior and personality.</param>
/// <param name="name">Optional name for the agent for identification purposes.</param>
/// <param name="description">Optional description of the agent's capabilities and purpose.</param>
/// <param name="tools">Optional collection of AI tools that the agent can use during conversations.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
public static ChatClientAgent CreateAIAgent(
this AssistantClient client,
string model,
string? instructions = null,
string? name = null,
string? description = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null) =>
client.CreateAIAgent(
model,
new ChatClientAgentOptions()
{
Name = name,
Description = description,
ChatOptions = tools is null && string.IsNullOrWhiteSpace(instructions) ? null : new ChatOptions()
{
Tools = tools,
Instructions = instructions
}
},
clientFactory,
loggerFactory,
services);
/// <summary>
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
/// </summary>
/// <param name="client">The OpenAI <see cref="AssistantClient" /> to use for the agent.</param>
/// <param name="model">The model identifier to use (e.g., "gpt-4").</param>
/// <param name="options">Full set of options to configure the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/> used by the agent.</param>
/// <param name="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
/// <param name="services">An optional <see cref="IServiceProvider"/> to use for resolving services required by the <see cref="AIFunction"/> instances being invoked.</param>
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Assistant service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="model"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="model"/> is empty or whitespace.</exception>
[Obsolete("The Assistants API has been deprecated. Please use the Responses API instead.")]
public static ChatClientAgent CreateAIAgent(
this AssistantClient client,
string model,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
IServiceProvider? services = null)
{
Throw.IfNull(client);
Throw.IfNullOrEmpty(model);
Throw.IfNull(options);
var assistantOptions = new AssistantCreationOptions()
{
Name = options.Name,
Description = options.Description,
Instructions = options.ChatOptions?.Instructions,
};
// Convert AITools to ToolDefinitions and ToolResources
var toolDefinitionsAndResources = ConvertAIToolsToToolDefinitions(options.ChatOptions?.Tools);
if (toolDefinitionsAndResources.ToolDefinitions is { Count: > 0 })
{
toolDefinitionsAndResources.ToolDefinitions.ForEach(x => assistantOptions.Tools.Add(x));
}
if (toolDefinitionsAndResources.ToolResources is not null)
{
assistantOptions.ToolResources = toolDefinitionsAndResources.ToolResources;
}
// Create the assistant in the assistant service.
var assistantCreateResult = client.CreateAssistant(model, assistantOptions);
var assistantId = assistantCreateResult.Value.Id;
// Build the local agent object.
var chatClient = client.AsIChatClient(assistantId);
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
var agentOptions = options.Clone();
agentOptions.Id = assistantId;
options.ChatOptions ??= new ChatOptions();
options.ChatOptions!.Tools = toolDefinitionsAndResources.FunctionToolsAndOtherTools;
return new ChatClientAgent(chatClient, agentOptions, loggerFactory, services);
}
/// <summary>
/// Creates an AI agent from an <see cref="AssistantClient"/> using the OpenAI Assistant API.
/// </summary>
@@ -51,6 +51,15 @@ public class Workflow
);
}
/// <summary>
/// Gets the collection of executor bindings, keyed by their ID.
/// </summary>
/// <returns>A copy of the executor bindings dictionary. Modifications do not affect the workflow.</returns>
public Dictionary<string, ExecutorBinding> ReflectExecutors()
{
return new Dictionary<string, ExecutorBinding>(this.ExecutorBindings);
}
/// <summary>
/// Gets the identifier of the starting executor of the workflow.
/// </summary>
@@ -73,7 +73,12 @@ public static partial class AIAgentExtensions
[Description("Input query to invoke the agent.")] string query,
CancellationToken cancellationToken)
{
var response = await agent.RunAsync(query, thread: thread, cancellationToken: cancellationToken).ConfigureAwait(false);
// Propagate any additional properties from the parent agent's run to the child agent if the parent is using a FunctionInvokingChatClient.
AgentRunOptions? agentRunOptions = FunctionInvokingChatClient.CurrentContext?.Options?.AdditionalProperties is AdditionalPropertiesDictionary dict
? new AgentRunOptions { AdditionalProperties = dict }
: null;
var response = await agent.RunAsync(query, thread: thread, options: agentRunOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return response.Text;
}
@@ -22,9 +22,7 @@ public class AIProjectClientCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
{
// Arrange.
@@ -43,20 +41,9 @@ public class AIProjectClientCreateTests
Description = AgentDescription,
ChatOptions = new() { Instructions = AgentInstructions }
}),
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
model: s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
Name = AgentName,
Description = AgentDescription,
ChatOptions = new() { Instructions = AgentInstructions }
}),
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
name: AgentName,
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }),
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
name: AgentName,
creationOptions: new AgentVersionCreationOptions(new PromptAgentDefinition(s_config.DeploymentName) { Instructions = AgentInstructions }) { Description = AgentDescription }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -84,9 +71,7 @@ public class AIProjectClientCreateTests
[Theory(Skip = "For manual testing only")]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
{
// Arrange.
@@ -120,21 +105,11 @@ public class AIProjectClientCreateTests
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]),
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateFileSearchTool(vectorStoreIds: [vectorStoreMetadata.Value.Id]).AsAITool()]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -157,9 +132,7 @@ public class AIProjectClientCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
{
// Arrange.
@@ -192,22 +165,12 @@ public class AIProjectClientCreateTests
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]),
// Foundry (definitions + resources provided directly)
"CreateWithFoundryOptionsAsync" => await this._client.CreateAIAgentAsync(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
"CreateWithFoundryOptionsSync" => this._client.CreateAIAgent(
model: s_config.DeploymentName,
name: AgentName,
instructions: AgentInstructions,
tools: [ResponseTool.CreateCodeInterpreterTool(new CodeInterpreterToolContainer(CodeInterpreterToolContainerConfiguration.CreateAutomaticContainerConfiguration([uploadedCodeFile.Id]))).AsAITool()]),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -229,7 +192,6 @@ public class AIProjectClientCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
{
// Arrange.
@@ -248,13 +210,6 @@ public class AIProjectClientCreateTests
Name = AgentName,
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
}),
"CreateWithChatClientAgentOptionsSync" => this._client.CreateAIAgent(
s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
Name = AgentName,
ChatOptions = new() { Instructions = AgentInstructions, Tools = [weatherFunction] }
}),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -20,9 +20,7 @@ public class AzureAIAgentsPersistentCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithCorrectMetadataAsync(string createMechanism)
{
// Arrange.
@@ -41,24 +39,11 @@ public class AzureAIAgentsPersistentCreateTests
Name = AgentName,
Description = AgentDescription
}),
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
ChatOptions = new() { Instructions = AgentInstructions },
Name = AgentName,
Description = AgentDescription
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
s_config.DeploymentName,
instructions: AgentInstructions,
name: AgentName,
description: AgentDescription),
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
instructions: AgentInstructions,
name: AgentName,
description: AgentDescription),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -85,9 +70,7 @@ public class AzureAIAgentsPersistentCreateTests
[Theory(Skip = "For manual testing only")]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithVectorStoresAsync(string createMechanism)
{
// Arrange.
@@ -125,26 +108,11 @@ public class AzureAIAgentsPersistentCreateTests
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [new HostedFileSearchTool() { Inputs = [new HostedVectorStoreContent(vectorStoreMetadata.Value.Id)] }]
}
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
s_config.DeploymentName,
instructions: AgentInstructions,
tools: [new FileSearchToolDefinition()],
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
instructions: AgentInstructions,
tools: [new FileSearchToolDefinition()],
toolResources: new ToolResources() { FileSearch = new([vectorStoreMetadata.Value.Id], null) }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -167,9 +135,7 @@ public class AzureAIAgentsPersistentCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
[InlineData("CreateWithFoundryOptionsAsync")]
[InlineData("CreateWithFoundryOptionsSync")]
public async Task CreateAgent_CreatesAgentWithCodeInterpreterAsync(string createMechanism)
{
// Arrange.
@@ -205,26 +171,11 @@ public class AzureAIAgentsPersistentCreateTests
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [new HostedCodeInterpreterTool() { Inputs = [new HostedFileContent(uploadedCodeFile.Id)] }]
}
}),
"CreateWithFoundryOptionsAsync" => await this._persistentAgentsClient.CreateAIAgentAsync(
s_config.DeploymentName,
instructions: AgentInstructions,
tools: [new CodeInterpreterToolDefinition()],
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
"CreateWithFoundryOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
instructions: AgentInstructions,
tools: [new CodeInterpreterToolDefinition()],
toolResources: new ToolResources() { CodeInterpreter = toolResource }),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -246,7 +197,6 @@ public class AzureAIAgentsPersistentCreateTests
[Theory]
[InlineData("CreateWithChatClientAgentOptionsAsync")]
[InlineData("CreateWithChatClientAgentOptionsSync")]
public async Task CreateAgent_CreatesAgentWithAIFunctionToolsAsync(string createMechanism)
{
// Arrange.
@@ -267,16 +217,6 @@ public class AzureAIAgentsPersistentCreateTests
Tools = [weatherFunction]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._persistentAgentsClient.CreateAIAgent(
s_config.DeploymentName,
options: new ChatClientAgentOptions()
{
ChatOptions = new()
{
Instructions = AgentInstructions,
Tools = [weatherFunction]
}
}),
_ => throw new InvalidOperationException($"Unknown create mechanism: {createMechanism}")
};
@@ -42,9 +42,9 @@ public class AgentResponseUpdateExtensionsTests
{
AgentResponseUpdate[] updates =
[
new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(1, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" },
new(ChatRole.Assistant, "Hello") { ResponseId = "someResponse", MessageId = "12345", CreatedAt = new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), AgentId = "agentId" },
new(new("human"), ", ") { AuthorName = "Someone", AdditionalProperties = new() { ["a"] = "b" } },
new(null, "world!") { CreatedAt = new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
new(null, "world!") { CreatedAt = new DateTimeOffset(2025, 2, 3, 4, 5, 6, TimeSpan.Zero), AdditionalProperties = new() { ["c"] = "d" } },
new() { Contents = [new UsageContent(new() { InputTokenCount = 1, OutputTokenCount = 2 })] },
new() { Contents = [new UsageContent(new() { InputTokenCount = 4, OutputTokenCount = 5 })] },
@@ -62,7 +62,7 @@ public class AgentResponseUpdateExtensionsTests
Assert.Equal(7, response.Usage.OutputTokenCount);
Assert.Equal("someResponse", response.ResponseId);
Assert.Equal(new DateTimeOffset(2, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
Assert.Equal(new DateTimeOffset(2024, 2, 3, 4, 5, 6, TimeSpan.Zero), response.CreatedAt);
Assert.Equal(2, response.Messages.Count);
@@ -226,13 +226,13 @@ public class AgentResponseUpdateExtensionsTests
// Unix epoch (as "null") should not overwrite
new(null, "b") { CreatedAt = unixEpoch },
// Newer timestamp should overwrite
// Newer timestamp should not overwrite (first timestamp wins)
new(null, "c") { CreatedAt = middle },
// Older timestamp should not overwrite
new(null, "d") { CreatedAt = early },
// Even newer timestamp should overwrite
// Even newer timestamp should not overwrite (first timestamp wins)
new(null, "e") { CreatedAt = late },
// Unix epoch should not overwrite again
@@ -249,20 +249,20 @@ public class AgentResponseUpdateExtensionsTests
Assert.Equal("abcdefg", response.Messages[0].Text);
Assert.Equal(ChatRole.Tool, response.Messages[0].Role);
Assert.Equal(late, response.Messages[0].CreatedAt);
Assert.Equal(late, response.CreatedAt);
Assert.Equal(early, response.Messages[0].CreatedAt);
Assert.Equal(early, response.CreatedAt);
}
public static IEnumerable<object?[]> ToAgentResponse_TimestampFolding_MemberData()
{
// Base test cases
// Base test cases - first non-null valid timestamp wins
var testCases = new (string? timestamp1, string? timestamp2, string? expectedTimestamp)[]
{
(null, null, null),
("2024-01-01T10:00:00Z", null, "2024-01-01T10:00:00Z"),
(null, "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T11:00:00Z"),
("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"),
("2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z"), // First timestamp wins
("2024-01-01T11:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T11:00:00Z"), // First timestamp wins
("2024-01-01T10:00:00Z", "1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z"),
("1970-01-01T00:00:00Z", "2024-01-01T10:00:00Z", "2024-01-01T10:00:00Z"),
};
@@ -18,44 +18,6 @@ namespace Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.Extensions;
public sealed class PersistentAgentsClientExtensionsTests
{
/// <summary>
/// Verify that GetAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void GetAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).GetAIAgent("test-agent"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgent throws ArgumentException when agentId is null or whitespace.
/// </summary>
[Fact]
public void GetAIAgent_WithNullOrWhitespaceAgentId_ThrowsArgumentException()
{
// Arrange
var mockClient = new Mock<PersistentAgentsClient>();
// Act & Assert - null agentId
var exception1 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(null!));
Assert.Equal("agentId", exception1.ParamName);
// Act & Assert - empty agentId
var exception2 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(""));
Assert.Equal("agentId", exception2.ParamName);
// Act & Assert - whitespace agentId
var exception3 = Assert.Throws<ArgumentException>(() =>
mockClient.Object.GetAIAgent(" "));
Assert.Equal("agentId", exception3.ParamName);
}
/// <summary>
/// Verify that GetAIAgentAsync throws ArgumentNullException when client is null.
/// </summary>
@@ -94,19 +56,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("agentId", exception3.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((PersistentAgentsClient)null!).CreateAIAgent("test-model"));
Assert.Equal("persistentAgentsClient", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgentAsync throws ArgumentNullException when client is null.
/// </summary>
@@ -124,14 +73,14 @@ public sealed class PersistentAgentsClientExtensionsTests
/// Verify that GetAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void GetAIAgent_WithClientFactory_AppliesFactoryCorrectly()
public async Task GetAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.GetAIAgent(
var agent = await client.GetAIAgentAsync(
agentId: "test-agent-id",
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
@@ -146,13 +95,13 @@ public sealed class PersistentAgentsClientExtensionsTests
/// Verify that GetAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void GetAIAgent_WithoutClientFactory_WorksNormally()
public async Task GetAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.GetAIAgent(agentId: "test-agent-id");
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id");
// Assert
Assert.NotNull(agent);
@@ -164,13 +113,13 @@ public sealed class PersistentAgentsClientExtensionsTests
/// Verify that GetAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void GetAIAgent_WithNullClientFactory_WorksNormally()
public async Task GetAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
{
// Arrange
PersistentAgentsClient client = CreateFakePersistentAgentsClient();
// Act
var agent = client.GetAIAgent(agentId: "test-agent-id", clientFactory: null);
var agent = await client.GetAIAgentAsync(agentId: "test-agent-id", clientFactory: null);
// Assert
Assert.NotNull(agent);
@@ -178,29 +127,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
{
// Arrange
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
// Act
var agent = client.CreateAIAgent(
model: "test-model",
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgentAsync with clientFactory parameter correctly applies the factory.
/// </summary>
@@ -223,42 +149,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.CreateAIAgent(model: "test-model");
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act
var agent = client.CreateAIAgent(model: "test-model", clientFactory: null);
// Assert
Assert.NotNull(agent);
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.Null(retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
@@ -372,33 +262,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("Original Instructions", agent.Instructions);
}
/// <summary>
/// Verify that GetAIAgent with agentId and options works correctly.
/// </summary>
[Fact]
public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
const string AgentId = "agent_abc123";
var options = new ChatClientAgentOptions
{
Name = "Override Name",
Description = "Override Description",
ChatOptions = new() { Instructions = "Override Instructions" }
};
// Act
var agent = client.GetAIAgent(AgentId, options);
// Assert
Assert.NotNull(agent);
Assert.Equal("Override Name", agent.Name);
Assert.Equal("Override Description", agent.Description);
Assert.Equal("Override Instructions", agent.Instructions);
}
/// <summary>
/// Verify that GetAIAgentAsync with agentId and options works correctly.
/// </summary>
@@ -509,23 +372,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("options", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgent throws ArgumentException when agentId is empty.
/// </summary>
[Fact]
public void GetAIAgent_WithOptionsAndEmptyAgentId_ThrowsArgumentException()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
var options = new ChatClientAgentOptions();
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() =>
client.GetAIAgent(string.Empty, options));
Assert.Equal("agentId", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty.
/// </summary>
@@ -543,33 +389,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("agentId", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent with options works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptions_WorksCorrectly()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
const string Model = "test-model";
var options = new ChatClientAgentOptions
{
Name = "Test Agent",
Description = "Test description",
ChatOptions = new() { Instructions = "Test instructions" }
};
// Act
var agent = client.CreateAIAgent(Model, options);
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
Assert.Equal("Test description", agent.Description);
Assert.Equal("Test instructions", agent.Instructions);
}
/// <summary>
/// Verify that CreateAIAgentAsync with options works correctly.
/// </summary>
@@ -597,38 +416,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("Test instructions", agent.Instructions);
}
/// <summary>
/// Verify that CreateAIAgent with options and clientFactory applies the factory correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
TestChatClient? testChatClient = null;
const string Model = "test-model";
var options = new ChatClientAgentOptions
{
Name = "Test Agent"
};
// Act
var agent = client.CreateAIAgent(
Model,
options,
clientFactory: (innerClient) => testChatClient = new TestChatClient(innerClient));
// Assert
Assert.NotNull(agent);
Assert.Equal("Test Agent", agent.Name);
// Verify that the custom chat client can be retrieved from the agent's service collection
var retrievedTestClient = agent.GetService<TestChatClient>();
Assert.NotNull(retrievedTestClient);
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgentAsync with options and clientFactory applies the factory correctly.
/// </summary>
@@ -661,22 +448,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Same(testChatClient, retrievedTestClient);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentNullException when options is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
client.CreateAIAgent("test-model", (ChatClientAgentOptions)null!));
Assert.Equal("options", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgentAsync throws ArgumentNullException when options is null.
/// </summary>
@@ -693,23 +464,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("options", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent throws ArgumentException when model is empty.
/// </summary>
[Fact]
public void CreateAIAgent_WithEmptyModel_ThrowsArgumentException()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
var options = new ChatClientAgentOptions();
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() =>
client.CreateAIAgent(string.Empty, options));
Assert.Equal("model", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgentAsync throws ArgumentException when model is empty.
/// </summary>
@@ -727,35 +481,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Equal("model", exception.ParamName);
}
/// <summary>
/// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
[Fact]
public void CreateAIAgent_WithServices_PassesServicesToAgent()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
var serviceProvider = new TestServiceProvider();
const string Model = "test-model";
// Act
var agent = client.CreateAIAgent(
Model,
instructions: "Test instructions",
name: "Test Agent",
services: serviceProvider);
// Assert
Assert.NotNull(agent);
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
var chatClient = agent.GetService<IChatClient>();
Assert.NotNull(chatClient);
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
Assert.NotNull(functionInvokingClient);
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
}
/// <summary>
/// Verify that CreateAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
@@ -785,30 +510,6 @@ public sealed class PersistentAgentsClientExtensionsTests
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
}
/// <summary>
/// Verify that GetAIAgent with services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
[Fact]
public void GetAIAgent_WithServices_PassesServicesToAgent()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
var serviceProvider = new TestServiceProvider();
// Act
var agent = client.GetAIAgent("agent_abc123", services: serviceProvider);
// Assert
Assert.NotNull(agent);
// Verify the IServiceProvider was passed through to the FunctionInvokingChatClient
var chatClient = agent.GetService<IChatClient>();
Assert.NotNull(chatClient);
var functionInvokingClient = chatClient.GetService<FunctionInvokingChatClient>();
Assert.NotNull(functionInvokingClient);
Assert.Same(serviceProvider, GetFunctionInvocationServices(functionInvokingClient));
}
/// <summary>
/// Verify that GetAIAgentAsync with services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
@@ -837,7 +538,7 @@ public sealed class PersistentAgentsClientExtensionsTests
/// Verify that CreateAIAgent with both clientFactory and services works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly()
public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync()
{
// Arrange
var client = CreateFakePersistentAgentsClient();
@@ -846,7 +547,7 @@ public sealed class PersistentAgentsClientExtensionsTests
const string Model = "test-model";
// Act
var agent = client.CreateAIAgent(
var agent = await client.CreateAIAgentAsync(
Model,
instructions: "Test instructions",
name: "Test Agent",
@@ -0,0 +1,960 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
[Collection("Samples")]
[Trait("Category", "SampleValidation")]
public sealed class ConsoleAppSamplesValidation(ITestOutputHelper outputHelper) : IAsyncLifetime
{
private const string DtsPort = "8080";
private const string RedisPort = "6379";
private static readonly string s_dotnetTargetFramework = GetTargetFramework();
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private static bool s_infrastructureStarted;
private static readonly string s_samplesPath = Path.GetFullPath(
Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "..", "..", "..", "..", "..", "samples", "DurableAgents", "ConsoleApps"));
private readonly ITestOutputHelper _outputHelper = outputHelper;
async Task IAsyncLifetime.InitializeAsync()
{
if (!s_infrastructureStarted)
{
await this.StartSharedInfrastructureAsync();
s_infrastructureStarted = true;
}
}
async Task IAsyncLifetime.DisposeAsync()
{
// Nothing to clean up
await Task.CompletedTask;
}
[Fact]
public async Task SingleAgentSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "01_SingleAgent");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
string agentResponse = string.Empty;
bool inputSent = false;
// Read output from logs queue
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for the agent's response. Unlike the interactive mode, we won't actually see a line
// that starts with "Joker: ". Instead, we'll see a line that looks like "You: Joker: ..." because
// the standard input is *not* echoed back to standard output.
if (line.Contains("Joker: ", StringComparison.OrdinalIgnoreCase))
{
// This will give us the first line of the agent's response, which is all we need to verify that the agent is working.
agentResponse = line.Substring("Joker: ".Length).Trim();
break;
}
else if (!inputSent)
{
// Send input to stdin after we've started seeing output from the app
await this.WriteInputAsync(process, "Tell me a joke about a pirate.", testTimeoutCts.Token);
inputSent = true;
}
}
Assert.True(inputSent, "Input was not sent to the agent");
Assert.NotEmpty(agentResponse);
// Send exit command
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task SingleAgentOrchestrationChainingSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "02_AgentOrchestration_Chaining");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Console app runs automatically, just wait for completion
string? line;
bool foundSuccess = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
}
if (line.Contains("Result:", StringComparison.OrdinalIgnoreCase))
{
string result = line.Substring("Result:".Length).Trim();
Assert.NotEmpty(result);
break;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
});
}
[Fact]
public async Task MultiAgentConcurrencySampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "03_AgentOrchestration_Concurrency");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Send input to stdin
await this.WriteInputAsync(process, "What is temperature?", testTimeoutCts.Token);
// Read output from logs queue
StringBuilder output = new();
string? line;
bool foundSuccess = false;
bool foundPhysicist = false;
bool foundChemist = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
output.AppendLine(line);
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
}
if (line.Contains("Physicist's response:", StringComparison.OrdinalIgnoreCase))
{
foundPhysicist = true;
}
if (line.Contains("Chemist's response:", StringComparison.OrdinalIgnoreCase))
{
foundChemist = true;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
// Stop reading once we have both responses
if (foundSuccess && foundPhysicist && foundChemist)
{
break;
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
Assert.True(foundPhysicist, "Physicist response not found.");
Assert.True(foundChemist, "Chemist response not found.");
});
}
[Fact]
public async Task MultiAgentConditionalSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "04_AgentOrchestration_Conditionals");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// Test with legitimate email
await this.TestSpamDetectionAsync(
process: process,
logs: logs,
emailId: "email-001",
emailContent: "Hi John. I wanted to follow up on our meeting yesterday about the quarterly report. Could you please send me the updated figures by Friday? Thanks!",
expectedSpam: false,
testTimeoutCts.Token);
// Restart the process for the second test
await process.WaitForExitAsync();
});
// Run second test with spam email
using CancellationTokenSource testTimeoutCts2 = this.CreateTestTimeoutCts();
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
await this.TestSpamDetectionAsync(
process,
logs,
emailId: "email-002",
emailContent: "URGENT! You've won $1,000,000! Click here now to claim your prize! Limited time offer! Don't miss out!",
expectedSpam: true,
testTimeoutCts2.Token);
});
}
private async Task TestSpamDetectionAsync(
Process process,
BlockingCollection<OutputLog> logs,
string emailId,
string emailContent,
bool expectedSpam,
CancellationToken cancellationToken)
{
// Send email content to stdin
await this.WriteInputAsync(process, emailContent, cancellationToken);
// Read output from logs queue
string? line;
bool foundSuccess = false;
while ((line = this.ReadLogLine(logs, cancellationToken)) != null)
{
if (line.Contains("Email sent", StringComparison.OrdinalIgnoreCase))
{
Assert.False(expectedSpam, "Email was sent, but was expected to be marked as spam.");
}
if (line.Contains("Email marked as spam", StringComparison.OrdinalIgnoreCase))
{
Assert.True(expectedSpam, "Email was marked as spam, but was expected to be sent.");
}
if (line.Contains("Orchestration completed successfully!", StringComparison.OrdinalIgnoreCase))
{
foundSuccess = true;
break;
}
// Check for failure
if (line.Contains("Orchestration failed!", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(foundSuccess, "Orchestration did not complete successfully.");
}
[Fact]
public async Task SingleAgentOrchestrationHITLSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "05_AgentOrchestration_HITL");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
// Start the HITL orchestration following the happy path from README
await this.WriteInputAsync(process, "The Future of Artificial Intelligence", testTimeoutCts.Token);
await this.WriteInputAsync(process, "3", testTimeoutCts.Token);
await this.WriteInputAsync(process, "72", testTimeoutCts.Token);
// Read output from logs queue
string? line;
bool rejectionSent = false;
bool approvalSent = false;
bool contentPublished = false;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// The second time we see this, we should send approval.
if (line.Contains("Content is ready for review", StringComparison.OrdinalIgnoreCase))
{
if (!rejectionSent)
{
// Prompt: Approve? (y/n):
await this.WriteInputAsync(process, "n", testTimeoutCts.Token);
// Prompt: Feedback (optional):
await this.WriteInputAsync(
process,
"The article needs more technical depth and better examples. Rewrite it with less than 300 words.",
testTimeoutCts.Token);
rejectionSent = true;
}
else if (!approvalSent)
{
// Prompt: Approve? (y/n):
await this.WriteInputAsync(process, "y", testTimeoutCts.Token);
// Prompt: Feedback (optional):
await this.WriteInputAsync(process, "Looks good!", testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
if (line.Contains("PUBLISHING: Content has been published", StringComparison.OrdinalIgnoreCase))
{
contentPublished = true;
break;
}
// Check for failure
if (line.Contains("Orchestration failed", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Orchestration failed.");
}
}
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
Assert.True(contentPublished, "Content was not published.");
});
}
[Fact]
public async Task LongRunningToolsSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "06_LongRunningTools");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test starting an agent that schedules a content generation orchestration
await this.WriteInputAsync(
process,
"Start a content generation workflow for the topic 'The Future of Artificial Intelligence'. Keep it less than 300 words.",
testTimeoutCts.Token);
// Read output from logs queue
bool rejectionSent = false;
bool approvalSent = false;
bool contentPublished = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
// Look for notification that content is ready. The first time we see this, we should send a rejection.
// The second time we see this, we should send approval.
if (line.Contains("NOTIFICATION: Please review the following content for approval", StringComparison.OrdinalIgnoreCase))
{
// Wait for the notification to be fully written to the console
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
if (!rejectionSent)
{
// Reject the content with feedback. Note that we need to send a newline character to the console first before sending the input.
await this.WriteInputAsync(
process,
"\nReject the content with feedback: Make it even shorter.",
testTimeoutCts.Token);
rejectionSent = true;
}
else if (!approvalSent)
{
// Approve the content. Note that we need to send a newline character to the console first before sending the input.
await this.WriteInputAsync(
process,
"\nApprove the content",
testTimeoutCts.Token);
approvalSent = true;
}
else
{
// This should never happen
Assert.Fail("Unexpected message found.");
}
}
// Look for success message
if (line.Contains("PUBLISHING: Content has been published successfully", StringComparison.OrdinalIgnoreCase))
{
contentPublished = true;
// Ask for the status of the workflow to confirm that it completed successfully.
await Task.Delay(TimeSpan.FromSeconds(1), testTimeoutCts.Token);
await this.WriteInputAsync(process, "\nGet the status of the workflow you previously started", testTimeoutCts.Token);
}
// Check for workflow completion or failure
if (contentPublished)
{
if (line.Contains("Completed", StringComparison.OrdinalIgnoreCase))
{
break;
}
else if (line.Contains("Failed", StringComparison.OrdinalIgnoreCase))
{
Assert.Fail("Workflow failed.");
}
}
}
Assert.True(rejectionSent, "Wasn't prompted with the first draft.");
Assert.True(approvalSent, "Wasn't prompted with the second draft.");
Assert.True(contentPublished, "Content was not published.");
});
}
[Fact]
public async Task ReliableStreamingSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "07_ReliableStreaming");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
// This test takes a bit longer to run due to the multiple agent interactions and the lengthy content generation.
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts(TimeSpan.FromSeconds(90));
// Test the agent endpoint with a simple prompt
await this.WriteInputAsync(process, "Plan a 5-day trip to Seattle. Include daily activities.", testTimeoutCts.Token);
// Read output from stdout - should stream in real-time
// NOTE: The sample uses Console.Write() for streaming chunks, which means content may not be line-buffered.
// We test the interrupt/resume flow by:
// 1. Waiting for at least 10 lines of content
// 2. Sending Enter to interrupt
// 3. Verifying we get "Last cursor" output
// 4. Sending Enter again to resume
// 5. Verifying we get more content and that we're not restarting from the beginning
string? line;
bool foundConversationStart = false;
int contentLinesBeforeInterrupt = 0;
int contentLinesAfterResume = 0;
bool foundLastCursor = false;
bool foundResumeMessage = false;
bool interrupted = false;
bool resumed = false;
// Read output with a reasonable timeout
using CancellationTokenSource readTimeoutCts = this.CreateTestTimeoutCts();
DateTime? interruptTime = null;
try
{
while ((line = this.ReadLogLine(logs, readTimeoutCts.Token)) != null)
{
// Look for the conversation start message (updated format)
if (line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase))
{
foundConversationStart = true;
continue;
}
// Check if this is a content line (not prompts or status messages)
bool isContentLine = !string.IsNullOrWhiteSpace(line) &&
!line.Contains("Conversation ID", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Press [Enter]", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("You:", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("exit", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Stream cancelled", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase) &&
!line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase);
// Phase 1: Collect content before interrupt
if (foundConversationStart && !interrupted && isContentLine)
{
contentLinesBeforeInterrupt++;
}
// Phase 2: Wait for enough content, then interrupt
// Interrupt after 2 lines to maximize chance of catching stream while active
// (streams can complete very quickly, so we need to interrupt early)
if (foundConversationStart && !interrupted && contentLinesBeforeInterrupt >= 2)
{
this._outputHelper.WriteLine($"Interrupting stream after {contentLinesBeforeInterrupt} content lines");
interrupted = true;
interruptTime = DateTime.Now;
// Send Enter to interrupt the stream
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
// Give the cancellation token a moment to be processed
// Use a longer delay to ensure cancellation propagates
await Task.Delay(TimeSpan.FromMilliseconds(300), testTimeoutCts.Token);
}
// Phase 3: Look for "Last cursor" message after interrupt
if (interrupted && !resumed && line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
{
foundLastCursor = true;
// Send Enter again to resume
this._outputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
// Phase 4: Look for resume message
if (resumed && line.Contains("Resuming conversation", StringComparison.OrdinalIgnoreCase))
{
foundResumeMessage = true;
}
// Phase 5: Collect content after resume
if (resumed && isContentLine)
{
contentLinesAfterResume++;
}
// Look for completion message - but don't break if we interrupted and haven't found Last cursor yet
// Allow some time after interrupt for the cancellation message to appear
if (line.Contains("Conversation completed", StringComparison.OrdinalIgnoreCase))
{
// If we interrupted but haven't found Last cursor, wait a bit more
if (interrupted && !foundLastCursor && interruptTime.HasValue)
{
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
if (timeSinceInterrupt < TimeSpan.FromSeconds(2))
{
// Continue reading for a bit more to catch the cancellation message
this._outputHelper.WriteLine("Stream completed naturally, but waiting for Last cursor message after interrupt...");
continue;
}
}
// Only break if we've completed the test or if stream completed without interruption
if (!interrupted || (resumed && foundResumeMessage && contentLinesAfterResume >= 5))
{
break;
}
}
// Stop once we've verified the interrupt/resume flow works
if (resumed && foundResumeMessage && contentLinesAfterResume >= 5)
{
this._outputHelper.WriteLine($"Successfully verified interrupt/resume: {contentLinesBeforeInterrupt} lines before, {contentLinesAfterResume} lines after");
break;
}
}
// If we interrupted but didn't find Last cursor, wait a bit more for it to appear
if (interrupted && !foundLastCursor && interruptTime.HasValue)
{
TimeSpan timeSinceInterrupt = DateTime.Now - interruptTime.Value;
if (timeSinceInterrupt < TimeSpan.FromSeconds(3))
{
this._outputHelper.WriteLine("Waiting for Last cursor message after interrupt...");
using CancellationTokenSource waitCts = new(TimeSpan.FromSeconds(2));
try
{
while ((line = this.ReadLogLine(logs, waitCts.Token)) != null)
{
if (line.Contains("Last cursor", StringComparison.OrdinalIgnoreCase))
{
foundLastCursor = true;
if (!resumed)
{
this._outputHelper.WriteLine("Resuming stream from last cursor");
await this.WriteInputAsync(process, string.Empty, testTimeoutCts.Token);
resumed = true;
}
break;
}
}
}
catch (OperationCanceledException)
{
// Timeout waiting for Last cursor
}
}
}
}
catch (OperationCanceledException)
{
// Timeout - check if we got enough to verify the flow
this._outputHelper.WriteLine($"Read timeout reached. Interrupted: {interrupted}, Resumed: {resumed}, Content before: {contentLinesBeforeInterrupt}, Content after: {contentLinesAfterResume}");
}
Assert.True(foundConversationStart, "Conversation start message not found.");
Assert.True(contentLinesBeforeInterrupt >= 2, $"Not enough content before interrupt (got {contentLinesBeforeInterrupt}).");
// If stream completed before interrupt could take effect, that's a timing issue
// but we should still verify we got the conversation started
if (!interrupted)
{
this._outputHelper.WriteLine("WARNING: Stream completed before interrupt could be sent. This may indicate the stream is too fast.");
}
Assert.True(interrupted, "Stream was not interrupted (may have completed too quickly).");
Assert.True(foundLastCursor, "'Last cursor' message not found after interrupt.");
Assert.True(resumed, "Stream was not resumed.");
Assert.True(foundResumeMessage, "Resume message not found.");
Assert.True(contentLinesAfterResume > 0, "No content received after resume (expected to continue from cursor, not restart).");
});
}
private static string GetTargetFramework()
{
string filePath = new Uri(typeof(ConsoleAppSamplesValidation).Assembly.Location).LocalPath;
string directory = Path.GetDirectoryName(filePath)!;
string tfm = Path.GetFileName(directory);
if (tfm.StartsWith("net", StringComparison.OrdinalIgnoreCase))
{
return tfm;
}
throw new InvalidOperationException($"Unable to find target framework in path: {filePath}");
}
private async Task StartSharedInfrastructureAsync()
{
this._outputHelper.WriteLine("Starting shared infrastructure for console app samples...");
// Start DTS emulator
await this.StartDtsEmulatorAsync();
// Start Redis
await this.StartRedisAsync();
// Wait for infrastructure to be ready
await Task.Delay(TimeSpan.FromSeconds(5));
}
private async Task StartDtsEmulatorAsync()
{
// Start DTS emulator if it's not already running
if (!await this.IsDtsEmulatorRunningAsync())
{
this._outputHelper.WriteLine("Starting DTS emulator...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "dts-emulator",
"-p", $"{DtsPort}:8080",
"-e", "DTS_USE_DYNAMIC_TASK_HUBS=true",
"mcr.microsoft.com/dts/dts-emulator:latest"
]);
}
}
private async Task StartRedisAsync()
{
if (!await this.IsRedisRunningAsync())
{
this._outputHelper.WriteLine("Starting Redis...");
await this.RunCommandAsync("docker", [
"run", "-d",
"--name", "redis",
"-p", $"{RedisPort}:6379",
"redis:latest"
]);
}
}
private async Task<bool> IsDtsEmulatorRunningAsync()
{
this._outputHelper.WriteLine($"Checking if DTS emulator is running at http://localhost:{DtsPort}/healthz...");
// DTS emulator doesn't support HTTP/1.1, so we need to use HTTP/2.0
using HttpClient http2Client = new()
{
DefaultRequestVersion = new Version(2, 0),
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact
};
try
{
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(30));
using HttpResponseMessage response = await http2Client.GetAsync(new Uri($"http://localhost:{DtsPort}/healthz"), timeoutCts.Token);
if (response.Content.Headers.ContentLength > 0)
{
string content = await response.Content.ReadAsStringAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"DTS emulator health check response: {content}");
}
if (response.IsSuccessStatusCode)
{
this._outputHelper.WriteLine("DTS emulator is running");
return true;
}
this._outputHelper.WriteLine($"DTS emulator is not running. Status code: {response.StatusCode}");
return false;
}
catch (HttpRequestException ex)
{
this._outputHelper.WriteLine($"DTS emulator is not running: {ex.Message}");
return false;
}
}
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 RunSampleTestAsync(string samplePath, Func<Process, BlockingCollection<OutputLog>, Task> testAction)
{
// Generate a unique TaskHub name for this sample test to prevent cross-test interference
// when multiple tests run together and share the same DTS emulator.
string uniqueTaskHubName = $"sample-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
// Start the console app
// Use BlockingCollection to safely read logs asynchronously captured from the process
using BlockingCollection<OutputLog> logsContainer = [];
using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName);
try
{
// Run the test
await testAction(appProcess, logsContainer);
}
catch (OperationCanceledException e)
{
throw new TimeoutException("Core test logic timed out!", e);
}
finally
{
logsContainer.CompleteAdding();
await this.StopProcessAsync(appProcess);
}
}
private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message);
/// <summary>
/// Writes a line to the process's stdin and flushes it.
/// Logs the input being sent for debugging purposes.
/// </summary>
private async Task WriteInputAsync(Process process, string input, CancellationToken cancellationToken)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{process.ProcessName}(in)]: {input}");
await process.StandardInput.WriteLineAsync(input);
await process.StandardInput.FlushAsync(cancellationToken);
}
/// <summary>
/// Reads a line from the logs queue, filtering for Information level logs (stdout).
/// Returns null if the collection is completed and empty, or if cancellation is requested.
/// </summary>
private string? ReadLogLine(BlockingCollection<OutputLog> logs, CancellationToken cancellationToken)
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
// Block until a log entry is available or cancellation is requested
// Take will throw OperationCanceledException if cancelled, or InvalidOperationException if collection is completed
OutputLog log = logs.Take(cancellationToken);
// Check for unhandled exceptions in the logs, which are never expected (but can happen)
if (log.Message.Contains("Unhandled exception"))
{
Assert.Fail("Console app encountered an unhandled exception.");
}
// Only return Information level logs (stdout), skip Error logs (stderr)
if (log.Level == LogLevel.Information)
{
return log.Message;
}
}
}
catch (OperationCanceledException)
{
// Cancellation requested
return null;
}
catch (InvalidOperationException)
{
// Collection is completed and empty
return null;
}
return null;
}
private Process StartConsoleApp(string samplePath, BlockingCollection<OutputLog> logs, string taskHubName)
{
ProcessStartInfo startInfo = new()
{
FileName = "dotnet",
Arguments = $"run --framework {s_dotnetTargetFramework}",
WorkingDirectory = samplePath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
};
string openAiEndpoint = s_configuration["AZURE_OPENAI_ENDPOINT"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_ENDPOINT env variable is not set.");
string openAiDeployment = s_configuration["AZURE_OPENAI_CHAT_DEPLOYMENT_NAME"] ??
throw new InvalidOperationException("The required AZURE_OPENAI_CHAT_DEPLOYMENT_NAME env variable is not set.");
void SetAndLogEnvironmentVariable(string key, string value)
{
this._outputHelper.WriteLine($"Setting environment variable for {startInfo.FileName} sub-process: {key}={value}");
startInfo.EnvironmentVariables[key] = value;
}
// Set required environment variables for the app
SetAndLogEnvironmentVariable("AZURE_OPENAI_ENDPOINT", openAiEndpoint);
SetAndLogEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT", openAiDeployment);
SetAndLogEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING",
$"Endpoint=http://localhost:{DtsPort};TaskHub={taskHubName};Authentication=None");
SetAndLogEnvironmentVariable("REDIS_CONNECTION_STRING", $"localhost:{RedisPort}");
Process process = new() { StartInfo = startInfo };
// Capture the output and error streams asynchronously
// These events fire asynchronously, so we add to the blocking collection which is thread-safe
process.ErrorDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(err)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Error, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
process.OutputDataReceived += (sender, e) =>
{
if (e.Data != null)
{
string logMessage = $"{DateTime.Now:HH:mm:ss.fff} [{startInfo.FileName}(out)]: {e.Data}";
this._outputHelper.WriteLine(logMessage);
Debug.WriteLine(logMessage);
try
{
logs.Add(new OutputLog(DateTime.Now, LogLevel.Information, e.Data));
}
catch (InvalidOperationException)
{
// Collection is completed, ignore
}
}
};
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the console app");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
return process;
}
private async Task RunCommandAsync(string command, string[] args)
{
await this.RunCommandAsync(command, workingDirectory: null, args: args);
}
private async Task RunCommandAsync(string command, string? workingDirectory, string[] args)
{
ProcessStartInfo startInfo = new()
{
FileName = command,
Arguments = string.Join(" ", args),
WorkingDirectory = workingDirectory,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
this._outputHelper.WriteLine($"Running command: {command} {string.Join(" ", args)}");
using Process process = new() { StartInfo = startInfo };
process.ErrorDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(err)]: {e.Data}");
process.OutputDataReceived += (sender, e) => this._outputHelper.WriteLine($"[{command}(out)]: {e.Data}");
if (!process.Start())
{
throw new InvalidOperationException("Failed to start the command");
}
process.BeginErrorReadLine();
process.BeginOutputReadLine();
using CancellationTokenSource cancellationTokenSource = new(TimeSpan.FromMinutes(1));
await process.WaitForExitAsync(cancellationTokenSource.Token);
this._outputHelper.WriteLine($"Command completed with exit code: {process.ExitCode}");
}
private async Task StopProcessAsync(Process process)
{
try
{
if (!process.HasExited)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Killing process {process.ProcessName}#{process.Id}");
process.Kill(entireProcessTree: true);
using CancellationTokenSource timeoutCts = new(TimeSpan.FromSeconds(10));
await process.WaitForExitAsync(timeoutCts.Token);
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Process exited: {process.Id}");
}
}
catch (Exception ex)
{
this._outputHelper.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Failed to stop process: {ex.Message}");
}
}
private CancellationTokenSource CreateTestTimeoutCts(TimeSpan? timeout = null)
{
TimeSpan testTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) : timeout ?? TimeSpan.FromSeconds(60);
return new CancellationTokenSource(testTimeout);
}
}
@@ -76,10 +76,14 @@ internal sealed class TestHelper : IDisposable
{
TestLoggerProvider loggerProvider = new(outputHelper);
// Generate a unique TaskHub name for this test instance to prevent cross-test interference
// when multiple tests run together and share the same DTS emulator.
string uniqueTaskHubName = $"test-{Guid.NewGuid().ToString("N").Substring(0, 6)}";
IHost host = Host.CreateDefaultBuilder()
.ConfigureServices((ctx, services) =>
{
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration);
string dtsConnectionString = GetDurableTaskSchedulerConnectionString(ctx.Configuration, uniqueTaskHubName);
// Register durable agents using the caller-supplied registration action and
// apply the default chat client for agents that don't supply one themselves.
@@ -107,11 +111,46 @@ internal sealed class TestHelper : IDisposable
return new TestHelper(loggerProvider, host, client);
}
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration)
private static string GetDurableTaskSchedulerConnectionString(IConfiguration configuration, string? taskHubName = null)
{
// The default value is for local development using the Durable Task Scheduler emulator.
return configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"]
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
string? connectionString = configuration["DURABLE_TASK_SCHEDULER_CONNECTION_STRING"];
if (connectionString != null)
{
// If a connection string is provided, replace the TaskHub name if a custom one is specified
if (taskHubName != null)
{
// Replace TaskHub in the connection string
if (connectionString.Contains("TaskHub=", StringComparison.OrdinalIgnoreCase))
{
// Find and replace the TaskHub value
int taskHubIndex = connectionString.IndexOf("TaskHub=", StringComparison.OrdinalIgnoreCase);
int taskHubValueStart = taskHubIndex + "TaskHub=".Length;
int taskHubValueEnd = connectionString.IndexOf(';', taskHubValueStart);
if (taskHubValueEnd == -1)
{
taskHubValueEnd = connectionString.Length;
}
connectionString = string.Concat(
connectionString.AsSpan(0, taskHubValueStart),
taskHubName,
connectionString.AsSpan(taskHubValueEnd));
}
else
{
// Append TaskHub if it doesn't exist
connectionString += $";TaskHub={taskHubName}";
}
}
return connectionString;
}
// Default connection string with unique TaskHub name
string defaultTaskHub = taskHubName ?? "default";
return $"Endpoint=http://localhost:8080;TaskHub={defaultTaskHub};Authentication=None";
}
internal static ChatClient GetAzureOpenAIChatClient(IConfiguration configuration)
@@ -23,7 +23,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactory_AppliesFactoryCorrectly()
public async Task CreateAIAgentAsync_WithClientFactory_AppliesFactoryCorrectlyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -31,7 +31,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
@@ -53,7 +53,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with clientFactory using AsBuilder pattern works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectly()
public async Task CreateAIAgentAsync_WithClientFactoryUsingAsBuilder_AppliesFactoryCorrectlyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -62,7 +62,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
clientFactory: (innerClient) =>
@@ -83,7 +83,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with options and clientFactory parameter correctly applies the factory.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptionsAndClientFactory_AppliesFactoryCorrectly()
public async Task CreateAIAgentAsync_WithOptionsAndClientFactory_AppliesFactoryCorrectlyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -97,7 +97,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
};
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
options,
clientFactory: (innerClient) => testChatClient);
@@ -117,14 +117,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent without clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithoutClientFactory_WorksNormally()
public async Task CreateAIAgentAsync_WithoutClientFactory_WorksNormallyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
name: "Test Agent");
@@ -142,14 +142,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with null clientFactory works normally.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClientFactory_WorksNormally()
public async Task CreateAIAgentAsync_WithNullClientFactory_WorksNormallyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
@@ -168,11 +168,11 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent throws ArgumentNullException when client is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullClient_ThrowsArgumentNullException()
public async Task CreateAIAgentAsync_WithNullClient_ThrowsArgumentNullExceptionAsync()
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((AssistantClient)null!).CreateAIAgent("test-model"));
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
((AssistantClient)null!).CreateAIAgentAsync("test-model"));
Assert.Equal("client", exception.ParamName);
}
@@ -181,14 +181,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent throws ArgumentNullException when model is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullModel_ThrowsArgumentNullException()
public async Task CreateAIAgentAsync_WithNullModel_ThrowsArgumentNullExceptionAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
assistantClient.CreateAIAgent(null!));
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
assistantClient.CreateAIAgentAsync(null!));
Assert.Equal("model", exception.ParamName);
}
@@ -197,14 +197,14 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with options throws ArgumentNullException when options is null.
/// </summary>
[Fact]
public void CreateAIAgent_WithNullOptions_ThrowsArgumentNullException()
public async Task CreateAIAgentAsync_WithNullOptions_ThrowsArgumentNullExceptionAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
assistantClient.CreateAIAgent("test-model", (ChatClientAgentOptions)null!));
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() =>
assistantClient.CreateAIAgentAsync("test-model", (ChatClientAgentOptions)null!));
Assert.Equal("options", exception.ParamName);
}
@@ -286,33 +286,6 @@ public sealed class OpenAIAssistantClientExtensionsTests
Assert.Equal("Original Instructions", agent.Instructions);
}
/// <summary>
/// Verify that GetAIAgent with agentId and options works correctly.
/// </summary>
[Fact]
public void GetAIAgent_WithAgentIdAndOptions_WorksCorrectly()
{
// Arrange
var assistantClient = new TestAssistantClient();
const string AgentId = "asst_abc123";
var options = new ChatClientAgentOptions
{
Name = "Override Name",
Description = "Override Description",
ChatOptions = new() { Instructions = "Override Instructions" }
};
// Act
var agent = assistantClient.GetAIAgent(AgentId, options);
// Assert
Assert.NotNull(agent);
Assert.Equal("Override Name", agent.Name);
Assert.Equal("Override Description", agent.Description);
Assert.Equal("Override Instructions", agent.Instructions);
}
/// <summary>
/// Verify that GetAIAgentAsync with agentId and options works correctly.
/// </summary>
@@ -423,23 +396,6 @@ public sealed class OpenAIAssistantClientExtensionsTests
Assert.Equal("options", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgent throws ArgumentException when agentId is empty.
/// </summary>
[Fact]
public void GetAIAgent_WithEmptyAgentId_ThrowsArgumentException()
{
// Arrange
var assistantClient = new TestAssistantClient();
var options = new ChatClientAgentOptions();
// Act & Assert
var exception = Assert.Throws<ArgumentException>(() =>
assistantClient.GetAIAgent(string.Empty, options));
Assert.Equal("agentId", exception.ParamName);
}
/// <summary>
/// Verify that GetAIAgentAsync throws ArgumentException when agentId is empty.
/// </summary>
@@ -461,7 +417,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
[Fact]
public void CreateAIAgent_WithServices_PassesServicesToAgent()
public async Task CreateAIAgentAsync_WithServices_PassesServicesToAgentAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -469,7 +425,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
@@ -490,7 +446,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with options and services parameter correctly passes it through to the ChatClientAgent.
/// </summary>
[Fact]
public void CreateAIAgent_WithOptionsAndServices_PassesServicesToAgent()
public async Task CreateAIAgentAsync_WithOptionsAndServices_PassesServicesToAgentAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -503,7 +459,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
};
// Act
var agent = assistantClient.CreateAIAgent(ModelId, options, services: serviceProvider);
var agent = await assistantClient.CreateAIAgentAsync(ModelId, options, services: serviceProvider);
// Assert
Assert.NotNull(agent);
@@ -570,7 +526,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
/// Verify that CreateAIAgent with both clientFactory and services works correctly.
/// </summary>
[Fact]
public void CreateAIAgent_WithClientFactoryAndServices_AppliesBothCorrectly()
public async Task CreateAIAgentAsync_WithClientFactoryAndServices_AppliesBothCorrectlyAsync()
{
// Arrange
var assistantClient = new TestAssistantClient();
@@ -579,7 +535,7 @@ public sealed class OpenAIAssistantClientExtensionsTests
const string ModelId = "test-model";
// Act
var agent = assistantClient.CreateAIAgent(
var agent = await assistantClient.CreateAIAgentAsync(
ModelId,
instructions: "Test instructions",
name: "Test Agent",
@@ -622,14 +578,9 @@ public sealed class OpenAIAssistantClientExtensionsTests
{
}
public override ClientResult<Assistant> CreateAssistant(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default)
public override Task<ClientResult<Assistant>> CreateAssistantAsync(string model, AssistantCreationOptions? options = null, CancellationToken cancellationToken = default)
{
return ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!;
}
public override ClientResult<Assistant> GetAssistant(string assistantId, CancellationToken cancellationToken = default)
{
return ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123", "name": "Original Name", "description": "Original Description", "instructions": "Original Instructions"}""")), new FakePipelineResponse())!;
return Task.FromResult<ClientResult<Assistant>>(ClientResult.FromValue(ModelReaderWriter.Read<Assistant>(BinaryData.FromString("""{"id": "asst_abc123"}""")), new FakePipelineResponse())!);
}
public override async Task<ClientResult<Assistant>> GetAssistantAsync(string assistantId, CancellationToken cancellationToken = default)
@@ -277,6 +277,48 @@ public class AgentExtensionsTests
Assert.Equal("Complex response", result.ToString());
}
[Fact]
public async Task CreateFromAgent_InvokeWithAdditionalProperties_PropagatesAdditionalPropertiesToChildAgentAsync()
{
// Arrange
var expectedResponse = new AgentResponse
{
AgentId = "agent-123",
ResponseId = "response-456",
CreatedAt = DateTimeOffset.UtcNow,
Messages = { new ChatMessage(ChatRole.Assistant, "Complex response") }
};
var testAgent = new TestAgent("TestAgent", "Test description", expectedResponse);
var aiFunction = testAgent.AsAIFunction();
// Use reflection to set the protected CurrentContext property
var context = new FunctionInvocationContext()
{
Options = new()
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
{ "customProperty1", "value1" },
{ "customProperty2", 42 }
}
}
};
SetFunctionInvokingChatClientCurrentContext(context);
// Act
var arguments = new AIFunctionArguments() { ["query"] = "Test query" };
var result = await aiFunction.InvokeAsync(arguments);
// Assert
Assert.NotNull(result);
Assert.Equal("Complex response", result.ToString());
Assert.NotNull(testAgent.ReceivedAgentRunOptions);
Assert.NotNull(testAgent.ReceivedAgentRunOptions!.AdditionalProperties);
Assert.Equal("value1", testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty1"]);
Assert.Equal(42, testAgent.ReceivedAgentRunOptions!.AdditionalProperties["customProperty2"]);
}
[Theory]
[InlineData("MyAgent", "MyAgent")]
[InlineData("Agent123", "Agent123")]
@@ -302,6 +344,22 @@ public class AgentExtensionsTests
Assert.Equal(expectedFunctionName, result.Name);
}
/// <summary>
/// Uses reflection to set the protected static CurrentContext property on FunctionInvokingChatClient.
/// </summary>
private static void SetFunctionInvokingChatClientCurrentContext(FunctionInvocationContext? context)
{
// Access the private static field _currentContext which is an AsyncLocal<FunctionInvocationContext?>
var currentContextField = typeof(FunctionInvokingChatClient).GetField(
"_currentContext",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
if (currentContextField?.GetValue(null) is AsyncLocal<FunctionInvocationContext?> asyncLocal)
{
asyncLocal.Value = context;
}
}
/// <summary>
/// Test implementation of AIAgent for testing purposes.
/// </summary>
@@ -334,6 +392,7 @@ public class AgentExtensionsTests
public override string? Description { get; }
public List<ChatMessage> ReceivedMessages { get; } = [];
public AgentRunOptions? ReceivedAgentRunOptions { get; private set; }
public CancellationToken LastCancellationToken { get; private set; }
public int RunAsyncCallCount { get; private set; }
@@ -346,6 +405,7 @@ public class AgentExtensionsTests
this.RunAsyncCallCount++;
this.LastCancellationToken = cancellationToken;
this.ReceivedMessages.AddRange(messages);
this.ReceivedAgentRunOptions = options;
if (this._exceptionToThrow is not null)
{
@@ -48,7 +48,7 @@ public class OpenAIAssistantClientExtensionsTests
Tools = [weatherFunction]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: s_config.ChatModelId!,
options: new ChatClientAgentOptions()
{
@@ -115,7 +115,7 @@ public class OpenAIAssistantClientExtensionsTests
Tools = [codeInterpreterTool]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: s_config.ChatModelId!,
options: new ChatClientAgentOptions()
{
@@ -193,7 +193,7 @@ public class OpenAIAssistantClientExtensionsTests
Tools = [fileSearchTool]
}
}),
"CreateWithChatClientAgentOptionsSync" => this._assistantClient.CreateAIAgent(
"CreateWithChatClientAgentOptionsSync" => await this._assistantClient.CreateAIAgentAsync(
model: s_config.ChatModelId!,
options: new ChatClientAgentOptions()
{
@@ -31,11 +31,9 @@ from agent_framework import (
AgentThread,
BaseAgent,
ChatMessage,
Contents,
DataContent,
Content,
Role,
TextContent,
UriContent,
normalize_messages,
prepend_agent_framework_to_user_agent,
)
from agent_framework.observability import use_agent_instrumentation
@@ -236,7 +234,7 @@ class A2AAgent(BaseAgent):
Yields:
An agent response item.
"""
messages = self._normalize_messages(messages)
messages = normalize_messages(messages)
a2a_message = self._prepare_message_for_a2a(messages[-1])
response_stream = self.client.send_message(a2a_message)
@@ -332,7 +330,7 @@ class A2AAgent(BaseAgent):
A2APart(
root=FilePart(
file=FileWithBytes(
bytes=_get_uri_data(content.uri),
bytes=_get_uri_data(content.uri), # type: ignore[arg-type]
mime_type=content.media_type,
),
metadata=content.additional_properties,
@@ -361,19 +359,19 @@ class A2AAgent(BaseAgent):
metadata=cast(dict[str, Any], message.additional_properties),
)
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Contents]:
"""Parse A2A Parts into Agent Framework Contents.
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
"""Parse A2A Parts into Agent Framework Content.
Transforms A2A protocol Parts into framework-native Content objects,
handling text, file (URI/bytes), and data parts with metadata preservation.
"""
contents: list[Contents] = []
contents: list[Content] = []
for part in parts:
inner_part = part.root
match inner_part.kind:
case "text":
contents.append(
TextContent(
Content.from_text(
text=inner_part.text,
additional_properties=inner_part.metadata,
raw_representation=inner_part,
@@ -382,7 +380,7 @@ class A2AAgent(BaseAgent):
case "file":
if isinstance(inner_part.file, FileWithUri):
contents.append(
UriContent(
Content.from_uri(
uri=inner_part.file.uri,
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
@@ -391,7 +389,7 @@ class A2AAgent(BaseAgent):
)
elif isinstance(inner_part.file, FileWithBytes):
contents.append(
DataContent(
Content.from_data(
data=base64.b64decode(inner_part.file.bytes),
media_type=inner_part.file.mime_type or "",
additional_properties=inner_part.metadata,
@@ -400,7 +398,7 @@ class A2AAgent(BaseAgent):
)
case "data":
contents.append(
TextContent(
Content.from_text(
text=json.dumps(inner_part.data),
additional_properties=inner_part.metadata,
raw_representation=inner_part,
+14 -18
View File
@@ -24,12 +24,8 @@ from agent_framework import (
AgentResponse,
AgentResponseUpdate,
ChatMessage,
DataContent,
ErrorContent,
HostedFileContent,
Content,
Role,
TextContent,
UriContent,
)
from agent_framework.a2a import A2AAgent
from pytest import fixture, raises
@@ -289,8 +285,8 @@ def test_parse_contents_from_a2a_conversion(a2a_agent: A2AAgent) -> None:
# Verify conversion
assert len(contents) == 2
assert isinstance(contents[0], TextContent)
assert isinstance(contents[1], TextContent)
assert contents[0].type == "text"
assert contents[1].type == "text"
assert contents[0].text == "First part"
assert contents[1].text == "Second part"
@@ -299,7 +295,7 @@ def test_prepare_message_for_a2a_with_error_content(a2a_agent: A2AAgent) -> None
"""Test _prepare_message_for_a2a with ErrorContent."""
# Create ChatMessage with ErrorContent
error_content = ErrorContent(message="Test error message")
error_content = Content.from_error(message="Test error message")
message = ChatMessage(role=Role.USER, contents=[error_content])
# Convert to A2A message
@@ -314,7 +310,7 @@ def test_prepare_message_for_a2a_with_uri_content(a2a_agent: A2AAgent) -> None:
"""Test _prepare_message_for_a2a with UriContent."""
# Create ChatMessage with UriContent
uri_content = UriContent(uri="http://example.com/file.pdf", media_type="application/pdf")
uri_content = Content.from_uri(uri="http://example.com/file.pdf", media_type="application/pdf")
message = ChatMessage(role=Role.USER, contents=[uri_content])
# Convert to A2A message
@@ -330,7 +326,7 @@ def test_prepare_message_for_a2a_with_data_content(a2a_agent: A2AAgent) -> None:
"""Test _prepare_message_for_a2a with DataContent."""
# Create ChatMessage with DataContent (base64 data URI)
data_content = DataContent(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
data_content = Content.from_uri(uri="data:text/plain;base64,SGVsbG8gV29ybGQ=", media_type="text/plain")
message = ChatMessage(role=Role.USER, contents=[data_content])
# Convert to A2A message
@@ -368,7 +364,7 @@ async def test_run_stream_with_message_response(a2a_agent: A2AAgent, mock_a2a_cl
assert len(updates[0].contents) == 1
content = updates[0].contents[0]
assert isinstance(content, TextContent)
assert content.type == "text"
assert content.text == "Streaming response from agent!"
assert updates[0].response_id == "msg-stream-123"
@@ -414,10 +410,10 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
message = ChatMessage(
role=Role.USER,
contents=[
TextContent(text="Here's the analysis:"),
DataContent(data=b"binary data", media_type="application/octet-stream"),
UriContent(uri="https://example.com/image.png", media_type="image/png"),
TextContent(text='{"structured": "data"}'),
Content.from_text(text="Here's the analysis:"),
Content.from_data(data=b"binary data", media_type="application/octet-stream"),
Content.from_uri(uri="https://example.com/image.png", media_type="image/png"),
Content.from_text(text='{"structured": "data"}'),
],
)
@@ -445,7 +441,7 @@ def test_parse_contents_from_a2a_with_data_part() -> None:
assert len(contents) == 1
assert isinstance(contents[0], TextContent)
assert contents[0].type == "text"
assert contents[0].text == '{"key": "value", "number": 42}'
assert contents[0].additional_properties == {"source": "test"}
@@ -470,7 +466,7 @@ def test_prepare_message_for_a2a_with_hosted_file() -> None:
# Create message with hosted file content
message = ChatMessage(
role=Role.USER,
contents=[HostedFileContent(file_id="hosted://storage/document.pdf")],
contents=[Content.from_hosted_file(file_id="hosted://storage/document.pdf")],
)
result = agent._prepare_message_for_a2a(message) # noqa: SLF001
@@ -507,7 +503,7 @@ def test_parse_contents_from_a2a_with_hosted_file_uri() -> None:
assert len(contents) == 1
assert isinstance(contents[0], UriContent)
assert contents[0].type == "uri"
assert contents[0].uri == "hosted://storage/document.pdf"
assert contents[0].media_type == "" # Converted None to empty string
@@ -17,12 +17,10 @@ from agent_framework import (
ChatMessage,
ChatResponse,
ChatResponseUpdate,
DataContent,
FunctionCallContent,
Content,
use_chat_middleware,
use_function_invocation,
)
from agent_framework._middleware import use_chat_middleware
from agent_framework._tools import use_function_invocation
from agent_framework._types import BaseContent, Contents
from agent_framework.observability import use_instrumentation
from ._event_converters import AGUIEventConverter
@@ -53,26 +51,11 @@ else:
logger: logging.Logger = logging.getLogger(__name__)
class ServerFunctionCallContent(BaseContent):
"""Wrapper for server function calls to prevent client re-execution.
All function calls from the remote server are server-side executions.
This wrapper prevents @use_function_invocation from trying to execute them again.
"""
function_call_content: FunctionCallContent
def __init__(self, function_call_content: FunctionCallContent) -> None:
"""Initialize with the function call content."""
super().__init__(type="server_function_call")
self.function_call_content = function_call_content
def _unwrap_server_function_call_contents(contents: MutableSequence[Contents | dict[str, Any]]) -> None:
"""Replace ServerFunctionCallContent instances with their underlying call content."""
def _unwrap_server_function_call_contents(contents: MutableSequence[Content | dict[str, Any]]) -> None:
"""Replace server_function_call instances with their underlying call content."""
for idx, content in enumerate(contents):
if isinstance(content, ServerFunctionCallContent):
contents[idx] = content.function_call_content # type: ignore[assignment]
if content.type == "server_function_call": # type: ignore[union-attr]
contents[idx] = content.function_call # type: ignore[assignment, union-attr]
TBaseChatClient = TypeVar("TBaseChatClient", bound=type[BaseChatClient[Any]])
@@ -93,7 +76,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
@wraps(original_get_streaming_response)
async def streaming_wrapper(self, *args: Any, **kwargs: Any) -> AsyncIterable[ChatResponseUpdate]:
async for update in original_get_streaming_response(self, *args, **kwargs):
_unwrap_server_function_call_contents(cast(MutableSequence[Contents | dict[str, Any]], update.contents))
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], update.contents))
yield update
chat_client.get_streaming_response = streaming_wrapper # type: ignore[assignment]
@@ -105,9 +88,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
response = await original_get_response(self, *args, **kwargs)
if response.messages:
for message in response.messages:
_unwrap_server_function_call_contents(
cast(MutableSequence[Contents | dict[str, Any]], message.contents)
)
_unwrap_server_function_call_contents(cast(MutableSequence[Content | dict[str, Any]], message.contents))
return response
chat_client.get_response = response_wrapper # type: ignore[assignment]
@@ -289,13 +270,13 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
last_message = messages[-1]
for content in last_message.contents:
if isinstance(content, DataContent) and content.media_type == "application/json":
if isinstance(content, Content) and content.type == "data" and content.media_type == "application/json":
try:
uri = content.uri
if uri.startswith("data:application/json;base64,"):
if uri.startswith("data:application/json;base64,"): # type: ignore[union-attr]
import base64
encoded_data = uri.split(",", 1)[1]
encoded_data = uri.split(",", 1)[1] # type: ignore[union-attr]
decoded_bytes = base64.b64decode(encoded_data)
state = json.loads(decoded_bytes.decode("utf-8"))
@@ -433,19 +414,19 @@ class AGUIChatClient(BaseChatClient[TAGUIChatOptions], Generic[TAGUIChatOptions]
)
# Distinguish client vs server tools
for i, content in enumerate(update.contents):
if isinstance(content, FunctionCallContent):
if content.type == "function_call": # type: ignore[attr-defined]
logger.debug(
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}"
f"[AGUIChatClient] Function call: {content.name}, in client_tool_set: {content.name in client_tool_set}" # type: ignore[attr-defined]
)
if content.name in client_tool_set:
if content.name in client_tool_set: # type: ignore[attr-defined]
# Client tool - let @use_function_invocation execute it
if not content.additional_properties:
content.additional_properties = {}
content.additional_properties["agui_thread_id"] = thread_id
if not content.additional_properties: # type: ignore[attr-defined]
content.additional_properties = {} # type: ignore[attr-defined]
content.additional_properties["agui_thread_id"] = thread_id # type: ignore[attr-defined]
else:
# Server tool - wrap so @use_function_invocation ignores it
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}")
self._register_server_tool_placeholder(content.name)
update.contents[i] = ServerFunctionCallContent(content) # type: ignore
logger.debug(f"[AGUIChatClient] Wrapping server tool: {content.name}") # type: ignore[union-attr]
self._register_server_tool_placeholder(content.name) # type: ignore[arg-type]
update.contents[i] = Content(type="server_function_call", function_call=content) # type: ignore
yield update
@@ -6,12 +6,9 @@ from typing import Any
from agent_framework import (
ChatResponseUpdate,
ErrorContent,
Content,
FinishReason,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
)
@@ -117,7 +114,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
message_id=self.current_message_id,
contents=[TextContent(text=delta)],
contents=[Content.from_text(text=delta)],
)
def _handle_text_message_end(self, event: dict[str, Any]) -> ChatResponseUpdate | None:
@@ -133,7 +130,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments="",
@@ -149,7 +146,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.ASSISTANT,
contents=[
FunctionCallContent(
Content.from_function_call(
call_id=self.current_tool_call_id or "",
name=self.current_tool_name or "",
arguments=delta,
@@ -170,7 +167,7 @@ class AGUIEventConverter:
return ChatResponseUpdate(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=tool_call_id,
result=result,
)
@@ -197,7 +194,7 @@ class AGUIEventConverter:
role=Role.ASSISTANT,
finish_reason=FinishReason.CONTENT_FILTER,
contents=[
ErrorContent(
Content.from_error(
message=error_message,
error_code="RUN_ERROR",
)
@@ -25,14 +25,11 @@ from ag_ui.core import (
)
from agent_framework import (
AgentResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
prepare_function_call_results,
)
from ._utils import extract_state_from_tool_args, generate_event_id, safe_json_parse
from ._utils import extract_state_from_tool_args, generate_event_id, make_json_safe, safe_json_parse
logger = logging.getLogger(__name__)
@@ -96,20 +93,22 @@ class AgentFrameworkEventBridge:
logger.info(f"Processing AgentRunUpdate with {len(update.contents)} content items")
for idx, content in enumerate(update.contents):
logger.info(f" Content {idx}: type={type(content).__name__}")
if isinstance(content, TextContent):
events.extend(self._handle_text_content(content))
elif isinstance(content, FunctionCallContent):
events.extend(self._handle_function_call_content(content))
elif isinstance(content, FunctionResultContent):
events.extend(self._handle_function_result_content(content))
elif isinstance(content, FunctionApprovalRequestContent):
events.extend(self._handle_function_approval_request_content(content))
match content.type:
case "text":
events.extend(self._handle_text_content(content))
case "function_call":
events.extend(self._handle_function_call_content(content))
case "function_result":
events.extend(self._handle_function_result_content(content))
case "function_approval_request":
events.extend(self._handle_function_approval_request_content(content))
case _:
logger.warning(f" Unsupported content type: {content.type}, skipping.")
return events
def _handle_text_content(self, content: TextContent) -> list[BaseEvent]:
def _handle_text_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
logger.info(f" TextContent found: length={len(content.text)}")
logger.info(f" TextContent found: length={len(content.text)}") # type: ignore[arg-type]
logger.info(
" Flags: skip_text_content=%s, should_stop_after_confirm=%s",
self.skip_text_content,
@@ -122,7 +121,7 @@ class AgentFrameworkEventBridge:
if self.should_stop_after_confirm:
logger.info(" SKIPPING TextContent: waiting for confirm_changes response")
self.suppressed_summary += content.text
self.suppressed_summary += content.text # type: ignore[operator]
logger.info(f" Suppressed summary length={len(self.suppressed_summary)}")
return events
@@ -150,14 +149,14 @@ class AgentFrameworkEventBridge:
events.append(event)
return events
def _handle_function_call_content(self, content: FunctionCallContent) -> list[BaseEvent]:
def _handle_function_call_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
if content.name:
logger.debug(f"Tool call: {content.name} (call_id: {content.call_id})")
if not content.name and not content.call_id and not self.current_tool_call_name:
args_length = len(str(content.arguments)) if content.arguments else 0
logger.warning(f"FunctionCallContent missing name and call_id. args_length={args_length}")
logger.warning(f"Content missing name and call_id. args_length={args_length}")
tool_call_id = self._coalesce_tool_call_id(content)
# Only emit ToolCallStartEvent once per tool call (when it's a new tool call)
@@ -178,7 +177,11 @@ class AgentFrameworkEventBridge:
self.current_tool_call_id = tool_call_id
if content.arguments:
delta_str = content.arguments if isinstance(content.arguments, str) else json.dumps(content.arguments)
delta_str = (
content.arguments
if isinstance(content.arguments, str)
else json.dumps(make_json_safe(content.arguments))
)
logger.info(f"Emitting ToolCallArgsEvent with delta_length={len(delta_str)}, id='{tool_call_id}'")
args_event = ToolCallArgsEvent(
tool_call_id=tool_call_id,
@@ -190,7 +193,7 @@ class AgentFrameworkEventBridge:
return events
def _coalesce_tool_call_id(self, content: FunctionCallContent) -> str:
def _coalesce_tool_call_id(self, content: Content) -> str:
if content.call_id:
return content.call_id
if self.current_tool_call_id:
@@ -286,7 +289,7 @@ class AgentFrameworkEventBridge:
self.pending_state_updates[state_key] = state_value
return events
def _handle_function_result_content(self, content: FunctionResultContent) -> list[BaseEvent]:
def _handle_function_result_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
if content.call_id:
end_event = ToolCallEndEvent(
@@ -310,7 +313,7 @@ class AgentFrameworkEventBridge:
result_event = ToolCallResultEvent(
message_id=result_message_id,
tool_call_id=content.call_id,
tool_call_id=content.call_id, # type: ignore[arg-type]
content=result_content,
role="tool",
)
@@ -367,7 +370,7 @@ class AgentFrameworkEventBridge:
self.current_tool_call_name = None
return events
def _emit_confirm_changes_tool_call(self, function_call: FunctionCallContent | None = None) -> list[BaseEvent]:
def _emit_confirm_changes_tool_call(self, function_call: Content | None = None) -> list[BaseEvent]:
"""Emit a confirm_changes tool call for Dojo UI compatibility.
Args:
@@ -392,7 +395,7 @@ class AgentFrameworkEventBridge:
args_dict = {
"function_name": function_call.name,
"function_call_id": function_call.call_id,
"function_arguments": function_call.parse_arguments() or {},
"function_arguments": make_json_safe(function_call.parse_arguments() or {}),
"steps": [
{
"description": f"Execute {function_call.name}",
@@ -419,7 +422,7 @@ class AgentFrameworkEventBridge:
logger.info("Set flag to stop run after confirm_changes")
return events
def _emit_function_approval_tool_call(self, function_call: FunctionCallContent) -> list[BaseEvent]:
def _emit_function_approval_tool_call(self, function_call: Content) -> list[BaseEvent]:
"""Emit a tool call that can drive UI approval for function requests."""
tool_call_name = "confirm_changes"
if self.approval_tool_name and self.approval_tool_name != function_call.name:
@@ -436,7 +439,7 @@ class AgentFrameworkEventBridge:
args_dict = {
"function_name": function_call.name,
"function_call_id": function_call.call_id,
"function_arguments": function_call.parse_arguments() or {},
"function_arguments": make_json_safe(function_call.parse_arguments() or {}),
"steps": [
{
"description": f"Execute {function_call.name}",
@@ -462,13 +465,13 @@ class AgentFrameworkEventBridge:
logger.info("Set flag to stop run after confirm_changes")
return events
def _handle_function_approval_request_content(self, content: FunctionApprovalRequestContent) -> list[BaseEvent]:
def _handle_function_approval_request_content(self, content: Content) -> list[BaseEvent]:
events: list[BaseEvent] = []
logger.info("=== FUNCTION APPROVAL REQUEST ===")
logger.info(f" Function: {content.function_call.name}")
logger.info(f" Call ID: {content.function_call.call_id}")
logger.info(f" Function: {content.function_call.name}") # type: ignore[union-attr]
logger.info(f" Call ID: {content.function_call.call_id}") # type: ignore[union-attr]
parsed_args = content.function_call.parse_arguments()
parsed_args = content.function_call.parse_arguments() # type: ignore[union-attr]
parsed_arg_keys = list(parsed_args.keys()) if parsed_args else "None"
logger.info(f" Parsed args keys: {parsed_arg_keys}")
@@ -478,12 +481,12 @@ class AgentFrameworkEventBridge:
list(self.predict_state_config.keys()) if self.predict_state_config else "None",
)
for state_key, config in self.predict_state_config.items():
if config["tool"] != content.function_call.name:
if config["tool"] != content.function_call.name: # type: ignore[union-attr]
continue
tool_arg_name = config["tool_argument"]
logger.info(
" MATCHED tool '%s' for state key '%s', arg='%s'",
content.function_call.name,
content.function_call.name, # type: ignore[union-attr]
state_key,
tool_arg_name,
)
@@ -500,11 +503,11 @@ class AgentFrameworkEventBridge:
)
events.append(state_snapshot)
if content.function_call.call_id:
if content.function_call.call_id: # type: ignore[union-attr]
end_event = ToolCallEndEvent(
tool_call_id=content.function_call.call_id,
tool_call_id=content.function_call.call_id, # type: ignore[union-attr]
)
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'")
logger.info(f"Emitting ToolCallEndEvent for approval-required tool '{content.function_call.call_id}'") # type: ignore[union-attr]
events.append(end_event)
# Emit the function_approval_request custom event for UI implementations that support it
@@ -513,18 +516,18 @@ class AgentFrameworkEventBridge:
value={
"id": content.id,
"function_call": {
"call_id": content.function_call.call_id,
"name": content.function_call.name,
"arguments": content.function_call.parse_arguments(),
"call_id": content.function_call.call_id, # type: ignore[union-attr]
"name": content.function_call.name, # type: ignore[union-attr]
"arguments": content.function_call.parse_arguments(), # type: ignore[union-attr]
},
},
)
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'")
logger.info(f"Emitting function_approval_request custom event for '{content.function_call.name}'") # type: ignore[union-attr]
events.append(approval_event)
# Emit a UI-friendly approval tool call for function approvals.
if self.require_confirmation:
events.extend(self._emit_function_approval_tool_call(content.function_call))
events.extend(self._emit_function_approval_tool_call(content.function_call)) # type: ignore[arg-type]
# Signal orchestrator to stop the run and wait for user approval response
self.should_stop_after_confirm = True
@@ -8,11 +8,8 @@ from typing import Any, cast
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Content,
Role,
TextContent,
prepare_function_call_results,
)
@@ -20,6 +17,7 @@ from ._utils import (
AGUI_TO_FRAMEWORK_ROLE,
FRAMEWORK_TO_AGUI_ROLE,
get_role_value,
make_json_safe,
normalize_agui_role,
safe_json_parse,
)
@@ -40,11 +38,11 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
tool_ids = {
str(content.call_id)
for content in msg.contents or []
if isinstance(content, FunctionCallContent) and content.call_id
if content.type == "function_call" and content.call_id
}
confirm_changes_call = None
for content in msg.contents or []:
if isinstance(content, FunctionCallContent) and content.name == "confirm_changes":
if content.type == "function_call" and content.name == "confirm_changes":
confirm_changes_call = content
break
@@ -59,7 +57,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
approval_call_ids: set[str] = set()
approval_accepted: bool | None = None
for content in msg.contents or []:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
if content.function_call and content.function_call.call_id:
approval_call_ids.add(str(content.function_call.call_id))
if approval_accepted is None:
@@ -79,7 +77,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_confirm_changes_id,
result="Confirmed" if approval_accepted else "Rejected",
)
@@ -93,12 +91,12 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
if pending_confirm_changes_id:
user_text = ""
for content in msg.contents or []:
if isinstance(content, TextContent):
user_text = content.text
if content.type == "text":
user_text = content.text # type: ignore[assignment]
break
try:
parsed = json.loads(user_text)
parsed = json.loads(user_text) # type: ignore[arg-type]
if "accepted" in parsed:
logger.info(
f"Injecting synthetic tool result for confirm_changes call_id={pending_confirm_changes_id}"
@@ -106,7 +104,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_confirm_changes_id,
result="Confirmed" if parsed.get("accepted") else "Rejected",
)
@@ -130,7 +128,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
synthetic_result = ChatMessage(
role="tool",
contents=[
FunctionResultContent(
Content.from_function_result(
call_id=pending_call_id,
result="Tool execution skipped - user provided follow-up message",
)
@@ -149,7 +147,7 @@ def _sanitize_tool_history(messages: list[ChatMessage]) -> list[ChatMessage]:
continue
keep = False
for content in msg.contents or []:
if isinstance(content, FunctionResultContent):
if content.type == "function_result" and content.call_id:
call_id = str(content.call_id)
if call_id in pending_tool_call_ids:
keep = True
@@ -175,7 +173,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
for idx, msg in enumerate(messages):
role_value = get_role_value(msg)
if role_value == "tool" and msg.contents and isinstance(msg.contents[0], FunctionResultContent):
if role_value == "tool" and msg.contents and msg.contents[0].type == "function_result":
call_id = str(msg.contents[0].call_id)
key: Any = (role_value, call_id)
@@ -184,7 +182,7 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
existing_msg = unique_messages[existing_idx]
existing_result = None
if existing_msg.contents and isinstance(existing_msg.contents[0], FunctionResultContent):
if existing_msg.contents and existing_msg.contents[0].type == "function_result":
existing_result = existing_msg.contents[0].result
new_result = msg.contents[0].result
@@ -198,11 +196,9 @@ def _deduplicate_messages(messages: list[ChatMessage]) -> list[ChatMessage]:
seen_keys[key] = len(unique_messages)
unique_messages.append(msg)
elif (
role_value == "assistant" and msg.contents and any(isinstance(c, FunctionCallContent) for c in msg.contents)
):
elif role_value == "assistant" and msg.contents and any(c.type == "function_call" for c in msg.contents):
tool_call_ids = tuple(
sorted(str(c.call_id) for c in msg.contents if isinstance(c, FunctionCallContent) and c.call_id)
sorted(str(c.call_id) for c in msg.contents if c.type == "function_call" and c.call_id)
)
key = (role_value, tool_call_ids)
@@ -270,20 +266,19 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
function_payload_dict = cast(dict[str, Any], function_payload)
existing_args = function_payload_dict.get("arguments")
if isinstance(existing_args, str):
function_payload_dict["arguments"] = json.dumps(modified_args)
function_payload_dict["arguments"] = json.dumps(make_json_safe(modified_args))
else:
function_payload_dict["arguments"] = modified_args
return
def _find_matching_func_call(call_id: str) -> FunctionCallContent | None:
def _find_matching_func_call(call_id: str) -> Content | None:
for prev_msg in result:
role_val = prev_msg.role.value if hasattr(prev_msg.role, "value") else str(prev_msg.role)
if role_val != "assistant":
continue
for content in prev_msg.contents or []:
if isinstance(content, FunctionCallContent):
if content.call_id == call_id and content.name != "confirm_changes":
return content
if content.type == "function_call" and content.call_id == call_id and content.name != "confirm_changes":
return content
return None
def _parse_arguments(arguments: Any) -> dict[str, Any] | None:
@@ -301,9 +296,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
continue
direct_call = None
confirm_call = None
sibling_calls: list[FunctionCallContent] = []
sibling_calls: list[Content] = []
for content in prev_msg.contents or []:
if not isinstance(content, FunctionCallContent):
if content.type != "function_call":
continue
if content.call_id == tool_call_id:
direct_call = content
@@ -383,7 +378,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# a proper FunctionApprovalResponseContent. This enables the agent framework
# to execute the approved tool (fix for GitHub issue #3034).
accepted = parsed.get("accepted", False) if parsed is not None else False
approval_payload_text = result_content if isinstance(result_content, str) else json.dumps(parsed)
approval_payload_text = (
result_content if isinstance(result_content, str) else json.dumps(make_json_safe(parsed))
)
# Log the full approval payload to debug modified arguments
import logging
@@ -407,7 +404,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
if not (
(m.role.value if hasattr(m.role, "value") else str(m.role)) == "tool"
and any(
isinstance(c, FunctionResultContent) and c.call_id == approval_call_id
c.type == "function_result" and c.call_id == approval_call_id
for c in (m.contents or [])
)
)
@@ -460,15 +457,17 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Keep the original tool call and AG-UI snapshot in sync with approved args.
updated_args = (
json.dumps(merged_args) if isinstance(matching_func_call.arguments, str) else merged_args
json.dumps(make_json_safe(merged_args))
if isinstance(matching_func_call.arguments, str)
else merged_args
)
matching_func_call.arguments = updated_args
_update_tool_call_arguments(messages, str(approval_call_id), merged_args)
# Create a new FunctionCallContent with the modified arguments
func_call_for_approval = FunctionCallContent(
call_id=matching_func_call.call_id,
name=matching_func_call.name,
arguments=json.dumps(filtered_args),
func_call_for_approval = Content.from_function_call(
call_id=matching_func_call.call_id, # type: ignore[arg-type]
name=matching_func_call.name, # type: ignore[arg-type]
arguments=json.dumps(make_json_safe(filtered_args)),
)
logger.info(f"Using modified arguments from approval: {filtered_args}")
else:
@@ -476,7 +475,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
func_call_for_approval = matching_func_call
# Create FunctionApprovalResponseContent for the agent framework
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=accepted,
id=str(approval_call_id),
function_call=func_call_for_approval,
@@ -491,7 +490,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Keep the old behavior for backwards compatibility
chat_msg = ChatMessage(
role=Role.USER,
contents=[TextContent(text=approval_payload_text)],
contents=[Content.from_text(text=approval_payload_text)],
additional_properties={"is_tool_result": True, "tool_call_id": str(tool_call_id or "")},
)
if "id" in msg:
@@ -511,7 +510,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
func_result = str(result_content)
chat_msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id=str(tool_call_id), result=func_result)],
contents=[Content.from_function_result(call_id=str(tool_call_id), result=func_result)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -527,21 +526,21 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
chat_msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id=str(tool_call_id), result=result_content)],
contents=[Content.from_function_result(call_id=str(tool_call_id), result=result_content)],
)
if "id" in msg:
chat_msg.message_id = msg["id"]
result.append(chat_msg)
continue
# If assistant message includes tool calls, convert to FunctionCallContent(s)
# If assistant message includes tool calls, convert to Content.from_function_call(s)
tool_calls = msg.get("tool_calls") or msg.get("toolCalls")
if tool_calls:
contents: list[Any] = []
# Include any assistant text content if present
content_text = msg.get("content")
if isinstance(content_text, str) and content_text:
contents.append(TextContent(text=content_text))
contents.append(Content.from_text(text=content_text))
# Convert each tool call entry
for tc in tool_calls:
if not isinstance(tc, dict):
@@ -558,7 +557,7 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
arguments = func_dict.get("arguments")
contents.append(
FunctionCallContent(
Content.from_function_call(
call_id=call_id,
name=name,
arguments=arguments,
@@ -580,14 +579,14 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
approval_contents: list[Any] = []
for approval in msg["function_approvals"]:
# Create FunctionCallContent with the modified arguments
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id=approval.get("call_id", ""),
name=approval.get("name", ""),
arguments=approval.get("arguments", {}),
)
# Create the approval response
approval_response = FunctionApprovalResponseContent(
approval_response = Content.from_function_approval_response(
approved=approval.get("approved", True),
id=approval.get("id", ""),
function_call=func_call,
@@ -599,9 +598,9 @@ def agui_messages_to_agent_framework(messages: list[dict[str, Any]]) -> list[Cha
# Regular text message
content = msg.get("content", "")
if isinstance(content, str):
chat_msg = ChatMessage(role=role, contents=[TextContent(text=content)])
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=content)])
else:
chat_msg = ChatMessage(role=role, contents=[TextContent(text=str(content))])
chat_msg = ChatMessage(role=role, contents=[Content.from_text(text=str(content))])
if "id" in msg:
chat_msg.message_id = msg["id"]
@@ -652,9 +651,9 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
tool_result_call_id: str | None = None
for content in msg.contents:
if isinstance(content, TextContent):
content_text += content.text
elif isinstance(content, FunctionCallContent):
if content.type == "text":
content_text += content.text # type: ignore[operator]
elif content.type == "function_call":
tool_calls.append(
{
"id": content.call_id,
@@ -665,7 +664,7 @@ def agent_framework_messages_to_agui(messages: list[ChatMessage] | list[dict[str
},
}
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
# Tool result content - extract call_id and result
tool_result_call_id = content.call_id
# Serialize result to string using core utility
@@ -702,8 +701,13 @@ def extract_text_from_contents(contents: list[Any]) -> str:
"""
text_parts: list[str] = []
for content in contents:
if isinstance(content, TextContent):
text_parts.append(content.text)
if type_ := getattr(content, "type", None):
if type_ == "text_reasoning":
continue
if text := getattr(content, "text", None):
text_parts.append(text)
continue
# TODO (moonbox3): should this handle both text and text_reasoning?
elif hasattr(content, "text"):
text_parts.append(content.text)
return "".join(text_parts)
@@ -768,7 +772,7 @@ def agui_messages_to_snapshot_format(messages: list[dict[str, Any]]) -> list[dic
if arguments is None:
function_payload_dict["arguments"] = ""
elif not isinstance(arguments, str):
function_payload_dict["arguments"] = json.dumps(arguments)
function_payload_dict["arguments"] = json.dumps(make_json_safe(arguments))
# Normalize tool_call_id to toolCallId for tool messages
normalized_msg["role"] = normalize_agui_role(normalized_msg.get("role"))
@@ -9,13 +9,10 @@ from typing import TYPE_CHECKING, Any
from ag_ui.core import StateSnapshotEvent
from agent_framework import (
ChatMessage,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
)
from .._utils import get_role_value, safe_json_parse
from .._utils import get_role_value, make_json_safe, safe_json_parse
if TYPE_CHECKING:
from .._events import AgentFrameworkEventBridge
@@ -37,9 +34,9 @@ def pending_tool_call_ids(messages: list[ChatMessage]) -> set[str]:
resolved_ids: set[str] = set()
for msg in messages:
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.call_id:
if content.type == "function_call" and content.call_id:
pending_ids.add(str(content.call_id))
elif isinstance(content, FunctionResultContent) and content.call_id:
elif content.type == "function_result" and content.call_id:
resolved_ids.add(str(content.call_id))
return pending_ids - resolved_ids
@@ -56,7 +53,7 @@ def is_state_context_message(message: ChatMessage) -> bool:
if get_role_value(message) != "system":
return False
for content in message.contents:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"): # type: ignore[union-attr]
return True
return False
@@ -139,7 +136,7 @@ def tool_calls_match_state(
if get_role_value(msg) != "assistant":
continue
for content in msg.contents:
if isinstance(content, FunctionCallContent) and content.name == tool_name:
if content.type == "function_call" and content.name == tool_name:
tool_args = safe_json_parse(content.arguments)
break
if tool_args is not None:
@@ -255,7 +252,7 @@ def build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any
return {}
safe_metadata: dict[str, Any] = {}
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
value_str = value if isinstance(value, str) else json.dumps(make_json_safe(value))
if len(value_str) > 512:
value_str = value_str[:512]
safe_metadata[key] = value_str
@@ -287,7 +284,7 @@ def collect_approved_state_snapshots(
if get_role_value(msg) != "user":
continue
for content in msg.contents:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
if not content.function_call or not content.approved:
continue
parsed_args = content.function_call.parse_arguments()
@@ -319,7 +316,7 @@ def collect_approved_state_snapshots(
return events
def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalResponseContent | None:
def latest_approval_response(messages: list[ChatMessage]) -> Content | None:
"""Get the latest approval response from messages.
Args:
@@ -332,12 +329,12 @@ def latest_approval_response(messages: list[ChatMessage]) -> FunctionApprovalRes
return None
last_message = messages[-1]
for content in last_message.contents:
if type(content) is FunctionApprovalResponseContent:
if content.type == "function_approval_response":
return content
return None
def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
def approval_steps(approval: Content) -> list[Any]:
"""Extract steps from an approval response.
Args:
@@ -346,9 +343,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
Returns:
List of steps, or empty list if none
"""
state_args: Any | None = None
if approval.additional_properties:
state_args = approval.additional_properties.get("ag_ui_state_args")
state_args = approval.additional_properties.get("ag_ui_state_args", None)
if isinstance(state_args, dict):
steps = state_args.get("steps")
if isinstance(steps, list):
@@ -365,7 +360,7 @@ def approval_steps(approval: FunctionApprovalResponseContent) -> list[Any]:
def is_step_based_approval(
approval: FunctionApprovalResponseContent,
approval: Content,
predict_state_config: dict[str, dict[str, str]] | None,
) -> bool:
"""Check if an approval is step-based.
@@ -6,7 +6,9 @@ import json
from typing import Any
from ag_ui.core import CustomEvent, EventType
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage, Content
from .._utils import make_json_safe
class StateManager:
@@ -67,11 +69,11 @@ class StateManager:
if conversation_has_tool_calls and not self._state_from_input:
return None
state_json = json.dumps(self.current_state, indent=2)
state_json = json.dumps(make_json_safe(self.current_state), indent=2)
return ChatMessage(
role="system",
contents=[
TextContent(
Content.from_text(
text=(
"Current state of the application:\n"
f"{state_json}\n\n"
@@ -25,13 +25,11 @@ from agent_framework import (
AgentProtocol,
AgentThread,
ChatAgent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
FunctionInvocationConfiguration,
)
from agent_framework._middleware import extract_and_merge_function_middleware
from agent_framework._tools import (
FunctionInvocationConfiguration,
_collect_approval_responses, # type: ignore
_replace_approval_contents_with_results, # type: ignore
_try_execute_function_calls, # type: ignore
@@ -245,7 +243,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
if not msg:
return False
return bool(msg.additional_properties.get("is_tool_result", False))
return bool((msg.additional_properties or {}).get("is_tool_result", False))
async def run(
self,
@@ -285,12 +283,12 @@ class HumanInTheLoopOrchestrator(Orchestrator):
last_message = context.last_message
if last_message:
for content in last_message.contents:
if isinstance(content, TextContent):
if content.type == "text":
tool_content_text = content.text
break
try:
tool_result = json.loads(tool_content_text)
tool_result = json.loads(tool_content_text) # type: ignore[arg-type]
accepted = tool_result.get("accepted", False)
steps = tool_result.get("steps", [])
@@ -328,7 +326,7 @@ class HumanInTheLoopOrchestrator(Orchestrator):
except json.JSONDecodeError:
logger.error(f"Failed to parse tool result: {tool_content_text}")
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}")
yield RunErrorEvent(message=f"Invalid tool result format: {tool_content_text[:100]}") # type: ignore[index]
yield event_bridge.create_run_finished_event()
@@ -390,7 +388,7 @@ class DefaultOrchestrator(Orchestrator):
response_format = None
if isinstance(context.agent, ChatAgent):
response_format = context.agent.default_options.get("response_format")
response_format = (context.agent.default_options or {}).get("response_format")
skip_text_content = response_format is not None
client_tools = convert_agui_tools_to_agent_framework(context.input_data.get("tools"))
@@ -441,25 +439,24 @@ class DefaultOrchestrator(Orchestrator):
logger.info(f" Message {i}: role={role}, id={msg_id}")
if hasattr(msg, "contents") and msg.contents:
for j, content in enumerate(msg.contents):
content_type = type(content).__name__
if isinstance(content, TextContent):
logger.debug(" Content %s: %s - text_length=%s", j, content_type, len(content.text))
elif isinstance(content, FunctionCallContent):
if content.type == "text":
logger.debug(" Content %s: %s - text_length=%s", j, content.type, len(content.text)) # type: ignore[arg-type]
elif content.type == "function_call":
arg_length = len(str(content.arguments)) if content.arguments else 0
logger.debug(
" Content %s: %s - %s args_length=%s", j, content_type, content.name, arg_length
" Content %s: %s - %s args_length=%s", j, content.type, content.name, arg_length
)
elif isinstance(content, FunctionResultContent):
elif content.type == "function_result":
result_preview = type(content.result).__name__ if content.result is not None else "None"
logger.debug(
" Content %s: %s - call_id=%s, result_type=%s",
j,
content_type,
content.type,
content.call_id,
result_preview,
)
else:
logger.debug(f" Content {j}: {content_type}")
logger.debug(f" Content {j}: {content.type}")
pending_tool_calls: list[dict[str, Any]] = []
tool_calls_by_id: dict[str, dict[str, Any]] = {}
@@ -536,16 +533,14 @@ class DefaultOrchestrator(Orchestrator):
logger.error("Failed to execute approved tool calls; injecting error results.")
approved_function_results = []
normalized_results: list[FunctionResultContent] = []
normalized_results: list[Content] = []
for idx, approval in enumerate(approved_responses):
if idx < len(approved_function_results) and isinstance(
approved_function_results[idx], FunctionResultContent
):
if idx < len(approved_function_results) and approved_function_results[idx].type == "function_result":
normalized_results.append(approved_function_results[idx])
continue
call_id = approval.function_call.call_id or approval.id
call_id = approval.function_call.call_id or approval.id # type: ignore[union-attr]
normalized_results.append(
FunctionResultContent(call_id=call_id, result="Error: Tool call invocation failed.")
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.") # type: ignore[arg-type]
)
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
@@ -661,8 +656,8 @@ class DefaultOrchestrator(Orchestrator):
if all_updates is not None:
all_updates.append(update)
if event_bridge.current_message_id is None and update.contents:
has_tool_call = any(isinstance(content, FunctionCallContent) for content in update.contents)
has_text = any(isinstance(content, TextContent) for content in update.contents)
has_tool_call = any(content.type == "function_call" for content in update.contents)
has_text = any(content.type == "text" for content in update.contents)
if has_tool_call and not has_text:
tool_message_id = generate_event_id()
event_bridge.current_message_id = tool_message_id
@@ -6,6 +6,7 @@ import sys
from typing import Any, TypedDict
from agent_framework import ChatOptions
from pydantic import BaseModel, Field
if sys.version_info >= (3, 13):
from typing import TypeVar
@@ -19,8 +20,6 @@ __all__ = [
"RunMetadata",
]
from pydantic import BaseModel, Field
class PredictStateConfig(TypedDict):
"""Configuration for predictive state updates."""
@@ -141,11 +141,14 @@ def make_json_safe(obj: Any) -> Any: # noqa: ANN401
if isinstance(obj, (datetime, date)):
return obj.isoformat()
if is_dataclass(obj):
return asdict(obj) # type: ignore[arg-type]
# asdict may return nested non-dataclass objects, so recursively make them safe
return make_json_safe(asdict(obj)) # type: ignore[arg-type]
if hasattr(obj, "model_dump"):
return obj.model_dump() # type: ignore[no-any-return]
return make_json_safe(obj.model_dump()) # type: ignore[no-any-return]
if hasattr(obj, "to_dict"):
return make_json_safe(obj.to_dict()) # type: ignore[no-any-return]
if hasattr(obj, "dict"):
return obj.dict() # type: ignore[no-any-return]
return make_json_safe(obj.dict()) # type: ignore[no-any-return]
if hasattr(obj, "__dict__"):
return {key: make_json_safe(value) for key, value in vars(obj).items()} # type: ignore[misc]
if isinstance(obj, (list, tuple)):
@@ -18,7 +18,7 @@ from ag_ui.core import (
TextMessageStartEvent,
ToolCallStartEvent,
)
from agent_framework import ChatAgent, ChatClientProtocol, ai_function
from agent_framework import ChatAgent, ChatClientProtocol, ChatMessage, Content, ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
from pydantic import BaseModel, Field
@@ -221,7 +221,6 @@ class TaskStepsAgentWithExecution:
chat_client = chat_agent.chat_client # type: ignore
# Build messages for summary call
from agent_framework._types import ChatMessage, TextContent
original_messages = input_data.get("messages", [])
@@ -234,7 +233,7 @@ class TaskStepsAgentWithExecution:
messages.append(
ChatMessage(
role=msg.get("role", "user"),
contents=[TextContent(text=content_str)],
contents=[Content.from_text(text=content_str)],
)
)
elif isinstance(msg, ChatMessage):
@@ -245,7 +244,7 @@ class TaskStepsAgentWithExecution:
ChatMessage(
role="user",
contents=[
TextContent(
Content.from_text(
text="The steps have been successfully executed. Provide a brief one-sentence summary."
)
],
@@ -50,11 +50,9 @@ async def main():
print("\nAssistant: ", end="", flush=True)
# Display text content as it streams
from agent_framework import TextContent
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(f"\033[96m{content.text}\033[0m", end="", flush=True)
if hasattr(content, "text") and content.text: # type: ignore[attr-defined]
print(f"\033[96m{content.text}\033[0m", end="", flush=True) # type: ignore[attr-defined]
# Display finish reason if present
if update.finish_reason:
@@ -73,11 +73,9 @@ async def streaming_example(client: AGUIChatClient, thread_id: str | None = None
if not thread_id and update.additional_properties:
thread_id = update.additional_properties.get("thread_id")
from agent_framework import TextContent
for content in update.contents:
if isinstance(content, TextContent) and content.text:
print(content.text, end="", flush=True)
if content.type == "text" and content.text: # type: ignore[attr-defined]
print(content.text, end="", flush=True) # type: ignore[attr-defined]
print("\n")
return thread_id
@@ -138,13 +136,11 @@ async def tool_example(client: AGUIChatClient, thread_id: str | None = None):
print(f"Assistant: {response.text}")
# Show tool calls if any
from agent_framework import FunctionCallContent
tool_called = False
for message in response.messages:
for content in message.contents:
if isinstance(content, FunctionCallContent):
print(f"\n[Tool Called: {content.name}]")
if content.type == "function_call": # type: ignore[attr-defined]
print(f"\n[Tool Called: {content.name}]") # type: ignore[attr-defined]
tool_called = True
if not tool_called:
@@ -176,7 +172,7 @@ async def conversation_example(client: AGUIChatClient):
# Second turn - using same thread
print("\nUser: What's my name?\n")
response2 = await client.get_response("What's my name?", metadata={"thread_id": thread_id})
response2 = await client.get_response("What's my name?", options={"metadata": {"thread_id": thread_id}})
print(f"Assistant: {response2.text}")
# Check if context was maintained
@@ -186,7 +182,7 @@ async def conversation_example(client: AGUIChatClient):
# Third turn
print("\nUser: Can you also tell me what 10 * 5 is?\n")
response3 = await client.get_response(
"Can you also tell me what 10 * 5 is?", metadata={"thread_id": thread_id}, tools=[calculate]
"Can you also tell me what 10 * 5 is?", options={"metadata": {"thread_id": thread_id}}, tools=[calculate]
)
print(f"Assistant: {response3.text}")
@@ -22,7 +22,7 @@ import asyncio
import logging
import os
from agent_framework import ChatAgent, FunctionCallContent, FunctionResultContent, TextContent, ai_function
from agent_framework import ChatAgent, ai_function
from agent_framework.ag_ui import AGUIChatClient
# Enable debug logging
@@ -141,8 +141,9 @@ async def main():
# Build from contents when no direct text
parts: list[str] = []
for c in getattr(m, "contents", []) or []:
if isinstance(c, FunctionCallContent):
args = c.arguments
content_type = getattr(c, "type", None)
if content_type == "function_call":
args = getattr(c, "arguments", None)
if isinstance(args, dict):
try:
import json as _json
@@ -152,12 +153,15 @@ async def main():
args_str = str(args)
else:
args_str = str(args or "{}")
parts.append(f"tool_call {c.name} {args_str}")
elif isinstance(c, FunctionResultContent):
parts.append(f"tool_result[{c.call_id}]: {str(c.result)[:40]}")
elif isinstance(c, TextContent):
if c.text:
parts.append(c.text)
parts.append(f"tool_call {getattr(c, 'name', '?')} {args_str}")
elif content_type == "function_result":
call_id = getattr(c, "call_id", "?")
result = getattr(c, "result", None)
parts.append(f"tool_result[{call_id}]: {str(result)[:40]}")
elif content_type == "text":
text = getattr(c, "text", None)
if text:
parts.append(text)
else:
typename = getattr(c, "type", c.__class__.__name__)
parts.append(f"<{typename}>")
@@ -11,14 +11,13 @@ from agent_framework import (
ChatOptions,
ChatResponse,
ChatResponseUpdate,
FunctionCallContent,
Content,
Role,
TextContent,
ai_function,
)
from pytest import MonkeyPatch
from agent_framework_ag_ui._client import AGUIChatClient, ServerFunctionCallContent
from agent_framework_ag_ui._client import AGUIChatClient
from agent_framework_ag_ui._http_service import AGUIHttpService
@@ -96,13 +95,11 @@ class TestAGUIChatClient:
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -121,12 +118,10 @@ class TestAGUIChatClient:
invalid_json = "not valid json"
state_b64 = base64.b64encode(invalid_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -200,8 +195,8 @@ class TestAGUIChatClient:
first_content = updates[1].contents[0]
second_content = updates[2].contents[0]
assert isinstance(first_content, TextContent)
assert isinstance(second_content, TextContent)
assert first_content.type == "text"
assert second_content.type == "text"
assert first_content.text == "Hello"
assert second_content.text == " world"
@@ -294,13 +289,12 @@ class TestAGUIChatClient:
updates.append(update)
function_calls = [
content for update in updates for content in update.contents if isinstance(content, FunctionCallContent)
content for update in updates for content in update.contents if content.type == "function_call"
]
assert function_calls
assert function_calls[0].name == "get_time_zone"
assert not any(
isinstance(content, ServerFunctionCallContent) for update in updates for content in update.contents
)
assert not any(content.type == "server_function_call" for update in updates for content in update.contents)
async def test_server_tool_calls_not_executed_locally(self, monkeypatch: MonkeyPatch) -> None:
"""Server tools should not trigger local function invocation even when client tools exist."""
@@ -343,13 +337,11 @@ class TestAGUIChatClient:
state_json = json.dumps(state_data)
state_b64 = base64.b64encode(state_json.encode("utf-8")).decode("utf-8")
from agent_framework import DataContent
messages = [
ChatMessage(role="user", text="Hello"),
ChatMessage(
role="user",
contents=[DataContent(uri=f"data:application/json;base64,{state_b64}")],
contents=[Content.from_uri(uri=f"data:application/json;base64,{state_b64}")],
),
]
@@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any
import pytest
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatMessage, ChatOptions, ChatResponseUpdate, Content
from pydantic import BaseModel
sys.path.insert(0, str(Path(__file__).parent))
@@ -23,7 +23,7 @@ async def test_agent_initialization_basic():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent[ChatOptions](
chat_client=StreamingChatClientStub(stream_fn),
@@ -45,7 +45,7 @@ async def test_agent_initialization_with_state_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"document": {"type": "string"}}
@@ -61,7 +61,7 @@ async def test_agent_initialization_with_predict_state_config():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
predict_config = {"document": {"tool": "write_doc", "tool_argument": "content"}}
@@ -77,7 +77,7 @@ async def test_agent_initialization_with_pydantic_state_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
class MyState(BaseModel):
document: str
@@ -100,7 +100,7 @@ async def test_run_started_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -124,7 +124,7 @@ async def test_predict_state_custom_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
predict_config = {
@@ -156,7 +156,7 @@ async def test_initial_state_snapshot_with_schema():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema = {"document": {"type": "string"}}
@@ -186,7 +186,7 @@ async def test_state_initialization_object_type():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"recipe": {"type": "object", "properties": {}}}
@@ -213,7 +213,7 @@ async def test_state_initialization_array_type():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
state_schema: dict[str, dict[str, Any]] = {"steps": {"type": "array", "items": {}}}
@@ -240,7 +240,7 @@ async def test_run_finished_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -262,7 +262,7 @@ async def test_tool_result_confirm_changes_accepted():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Document updated")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Document updated")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -309,7 +309,7 @@ async def test_tool_result_confirm_changes_rejected():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -343,7 +343,7 @@ async def test_tool_result_function_approval_accepted():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -389,7 +389,7 @@ async def test_tool_result_function_approval_rejected():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="OK")])
yield ChatResponseUpdate(contents=[Content.from_text(text="OK")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -431,7 +431,7 @@ async def test_thread_metadata_tracking():
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -462,7 +462,7 @@ async def test_state_context_injection():
metadata = options.get("metadata")
if metadata:
thread_metadata.update(metadata)
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -492,7 +492,7 @@ async def test_no_messages_provided():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -516,7 +516,7 @@ async def test_message_end_event_emission():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Hello world")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Hello world")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(agent=agent)
@@ -602,7 +602,7 @@ async def test_suppressed_summary_with_document_state():
async def stream_fn(
messages: MutableSequence[ChatMessage], options: dict[str, Any], **kwargs: Any
) -> AsyncIterator[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[TextContent(text="Response")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Response")])
agent = ChatAgent(name="test_agent", instructions="Test", chat_client=StreamingChatClientStub(stream_fn))
wrapper = AgentFrameworkAgent(
@@ -650,7 +650,7 @@ async def test_agent_with_use_service_thread_is_false():
thread = kwargs.get("thread")
request_service_thread_id = thread.service_thread_id if thread else None
yield ChatResponseUpdate(
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
@@ -677,7 +677,7 @@ async def test_agent_with_use_service_thread_is_true():
thread = kwargs.get("thread")
request_service_thread_id = thread.service_thread_id if thread else None
yield ChatResponseUpdate(
contents=[TextContent(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
contents=[Content.from_text(text="Response")], response_id="resp_67890", conversation_id="conv_12345"
)
agent = ChatAgent(chat_client=StreamingChatClientStub(stream_fn))
@@ -693,7 +693,7 @@ async def test_agent_with_use_service_thread_is_true():
async def test_function_approval_mode_executes_tool():
"""Test that function approval with approval_mode='always_require' sends the correct messages."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework import ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@@ -712,7 +712,7 @@ async def test_function_approval_mode_executes_tool():
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Processing completed")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Processing completed")])
agent = ChatAgent(
chat_client=StreamingChatClientStub(stream_fn),
@@ -770,7 +770,7 @@ async def test_function_approval_mode_executes_tool():
tool_result_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
tool_result_found = True
assert content.call_id == "call_get_datetime_123"
assert content.result == "2025/12/01 12:00:00"
@@ -784,7 +784,7 @@ async def test_function_approval_mode_executes_tool():
async def test_function_approval_mode_rejection():
"""Test that function approval rejection creates a rejection response."""
from agent_framework import FunctionResultContent, ai_function
from agent_framework import ai_function
from agent_framework.ag_ui import AgentFrameworkAgent
messages_received: list[Any] = []
@@ -803,7 +803,7 @@ async def test_function_approval_mode_rejection():
# Capture the messages received by the chat client
messages_received.clear()
messages_received.extend(messages)
yield ChatResponseUpdate(contents=[TextContent(text="Operation cancelled")])
yield ChatResponseUpdate(contents=[Content.from_text(text="Operation cancelled")])
agent = ChatAgent(
name="test_agent",
@@ -855,7 +855,7 @@ async def test_function_approval_mode_rejection():
rejection_found = False
for msg in messages_received:
for content in msg.contents:
if isinstance(content, FunctionResultContent):
if content.type == "function_result":
rejection_found = True
assert content.call_id == "call_delete_123"
assert content.result == "Error: Tool call invocation was rejected by user."
@@ -12,7 +12,7 @@ from ag_ui.core import (
ToolCallResultEvent,
ToolCallStartEvent,
)
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -22,7 +22,7 @@ async def test_tool_call_flow():
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Step 1: Tool call starts
tool_call = FunctionCallContent(
tool_call = Content.from_function_call(
call_id="weather-123",
name="get_weather",
arguments={"location": "Seattle"},
@@ -44,7 +44,7 @@ async def test_tool_call_flow():
assert "Seattle" in args_event.delta
# Step 2: Tool result comes back
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="weather-123",
result="Weather in Seattle: Rainy, 52°F",
)
@@ -71,8 +71,8 @@ async def test_text_with_tool_call():
bridge = AgentFrameworkEventBridge(run_id="test-run", thread_id="test-thread")
# Agent says something then calls a tool
text_content = TextContent(text="Let me check the weather for you.")
tool_call = FunctionCallContent(
text_content = Content.from_text(text="Let me check the weather for you.")
tool_call = Content.from_function_call(
call_id="weather-456",
name="get_forecast",
arguments={"location": "San Francisco", "days": 3},
@@ -102,9 +102,9 @@ async def test_multiple_tool_results():
# Multiple tool results
results = [
FunctionResultContent(call_id="tool-1", result="Result 1"),
FunctionResultContent(call_id="tool-2", result="Result 2"),
FunctionResultContent(call_id="tool-3", result="Result 3"),
Content.from_function_result(call_id="tool-1", result="Result 1"),
Content.from_function_result(call_id="tool-2", result="Result 2"),
Content.from_function_result(call_id="tool-3", result="Result 3"),
]
update = AgentResponseUpdate(contents=results)
@@ -3,7 +3,7 @@
"""Tests for document writer predictive state flow with confirm_changes."""
from ag_ui.core import EventType, StateDeltaEvent, ToolCallArgsEvent, ToolCallEndEvent, ToolCallStartEvent
from agent_framework import AgentResponseUpdate, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -21,7 +21,7 @@ async def test_streaming_document_with_state_deltas():
)
# Simulate streaming tool call - first chunk with name
tool_call_start = FunctionCallContent(
tool_call_start = Content.from_function_call(
call_id="call_123",
name="write_document_local",
arguments='{"document":"Once',
@@ -34,7 +34,9 @@ async def test_streaming_document_with_state_deltas():
assert any(e.type == EventType.TOOL_CALL_ARGS for e in events1)
# Second chunk - incomplete JSON, should try partial extraction
tool_call_chunk2 = FunctionCallContent(call_id="call_123", name="write_document_local", arguments=" upon a time")
tool_call_chunk2 = Content.from_function_call(
call_id="call_123", name="write_document_local", arguments=" upon a time"
)
update2 = AgentResponseUpdate(contents=[tool_call_chunk2])
events2 = await bridge.from_agent_run_update(update2)
@@ -71,7 +73,7 @@ async def test_confirm_changes_emission():
bridge.pending_state_updates = {"document": "A short story"}
# Tool result
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="call_123",
result="Document written.",
)
@@ -115,7 +117,7 @@ async def test_text_suppression_before_confirm():
bridge.should_stop_after_confirm = True
# Text content that should be suppressed
text = TextContent(text="I have written a story about pirates.")
text = Content.from_text(text="I have written a story about pirates.")
update = AgentResponseUpdate(contents=[text])
events = await bridge.from_agent_run_update(update)
@@ -146,7 +148,7 @@ async def test_no_confirm_for_non_predictive_tools():
# Different tool (not in predict_state_config)
bridge.current_tool_call_name = "get_weather"
tool_result = FunctionResultContent(
tool_result = Content.from_function_result(
call_id="call_456",
result="Sunny, 72°F",
)
@@ -175,7 +177,7 @@ async def test_state_delta_deduplication():
)
# First tool call with document
tool_call1 = FunctionCallContent(
tool_call1 = Content.from_function_call(
call_id="call_1",
name="write_document_local",
arguments='{"document":"Same text"}',
@@ -189,7 +191,7 @@ async def test_state_delta_deduplication():
# Second tool call with SAME document (shouldn't emit new delta)
bridge.current_tool_call_name = "write_document_local"
tool_call2 = FunctionCallContent(
tool_call2 = Content.from_function_call(
call_id="call_2",
name="write_document_local",
arguments='{"document":"Same text"}', # Identical content
@@ -216,7 +218,7 @@ async def test_predict_state_config_multiple_fields():
)
# Tool call with both fields
tool_call = FunctionCallContent(
tool_call = Content.from_function_call(
call_id="call_999",
name="create_post",
arguments='{"title":"My Post","body":"Post content"}',
+2 -2
View File
@@ -6,7 +6,7 @@ import json
import sys
from pathlib import Path
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatResponseUpdate, Content
from fastapi import FastAPI, Header, HTTPException
from fastapi.params import Depends
from fastapi.testclient import TestClient
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
def build_chat_client(response_text: str = "Test response") -> StreamingChatClientStub:
"""Create a typed chat client stub for endpoint tests."""
updates = [ChatResponseUpdate(contents=[TextContent(text=response_text)])]
updates = [ChatResponseUpdate(contents=[Content.from_text(text=response_text)])]
return StreamingChatClientStub(stream_from_updates(updates))
@@ -6,10 +6,7 @@ import json
from agent_framework import (
AgentResponseUpdate,
FunctionApprovalRequestContent,
FunctionCallContent,
FunctionResultContent,
TextContent,
Content,
)
@@ -19,7 +16,7 @@ async def test_basic_text_message_conversion():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[TextContent(text="Hello")])
update = AgentResponseUpdate(contents=[Content.from_text(text="Hello")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -35,8 +32,8 @@ async def test_text_message_streaming():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -61,7 +58,7 @@ async def test_skip_text_content_for_structured_outputs():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread", skip_text_content=True)
update = AgentResponseUpdate(contents=[TextContent(text='{"result": "data"}')])
update = AgentResponseUpdate(contents=[Content.from_text(text='{"result": "data"}')])
events = await bridge.from_agent_run_update(update)
# No events should be emitted
@@ -74,9 +71,9 @@ async def test_skip_text_content_for_empty_text():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update2 = AgentResponseUpdate(contents=[TextContent(text="")]) # Empty chunk
update3 = AgentResponseUpdate(contents=[TextContent(text="world")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="")]) # Empty chunk
update3 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -105,7 +102,7 @@ async def test_tool_call_with_name():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
update = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 1
@@ -121,15 +118,17 @@ async def test_tool_call_streaming_args():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk: name only
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search_web", call_id="call_123")])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search_web", call_id="call_123")])
events1 = await bridge.from_agent_run_update(update1)
# Second chunk: arguments chunk 1 (name can be empty string for continuation)
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='{"query": "')])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_123", arguments='{"query": "')]
)
events2 = await bridge.from_agent_run_update(update2)
# Third chunk: arguments chunk 2
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_123", arguments='AI"}')])
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_123", arguments='AI"}')])
events3 = await bridge.from_agent_run_update(update3)
# First update: ToolCallStartEvent
@@ -167,9 +166,11 @@ async def test_streaming_tool_call_no_duplicate_start_events():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# Simulate streaming tool call: first chunk has name, subsequent chunks have name=""
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="get_weather", call_id="call_789")])
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='{"loc":')])
update3 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_789", arguments='"SF"}')])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="get_weather", call_id="call_789")])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_789", arguments='{"loc":')]
)
update3 = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="call_789", arguments='"SF"}')])
events1 = await bridge.from_agent_run_update(update1)
events2 = await bridge.from_agent_run_update(update2)
@@ -193,7 +194,7 @@ async def test_tool_result_with_dict():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
result_data = {"status": "success", "count": 42}
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=result_data)])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=result_data)])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallEndEvent + ToolCallResultEvent
@@ -214,7 +215,7 @@ async def test_tool_result_with_string():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result="Search complete")])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result="Search complete")])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -229,7 +230,7 @@ async def test_tool_result_with_none():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=None)])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=None)])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -247,8 +248,8 @@ async def test_multiple_tool_results_in_sequence():
update = AgentResponseUpdate(
contents=[
FunctionResultContent(call_id="call_1", result="Result 1"),
FunctionResultContent(call_id="call_2", result="Result 2"),
Content.from_function_result(call_id="call_1", result="Result 1"),
Content.from_function_result(call_id="call_2", result="Result 2"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -272,12 +273,12 @@ async def test_function_approval_request_basic():
require_confirmation=False,
)
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval = FunctionApprovalRequestContent(
approval = Content.from_function_approval_request(
id="approval_001",
function_call=func_call,
)
@@ -312,8 +313,8 @@ async def test_empty_predict_state_config():
# Tool call with arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
FunctionResultContent(call_id="call_1", result="Done"),
Content.from_function_call(name="write_doc", call_id="call_1", arguments='{"content": "test"}'),
Content.from_function_result(call_id="call_1", result="Done"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -347,8 +348,8 @@ async def test_tool_not_in_predict_state_config():
# Different tool name
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
FunctionResultContent(call_id="call_1", result="Results"),
Content.from_function_call(name="search_web", call_id="call_1", arguments='{"query": "AI"}'),
Content.from_function_result(call_id="call_1", result="Results"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -376,8 +377,8 @@ async def test_state_management_tracking():
# Streaming tool call
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Hello"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Hello"}'),
]
)
await bridge.from_agent_run_update(update1)
@@ -387,7 +388,7 @@ async def test_state_management_tracking():
assert bridge.pending_state_updates["document"] == "Hello"
# Tool result should update current_state
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# current_state should be updated
@@ -413,12 +414,12 @@ async def test_wildcard_tool_argument():
# Complete tool call with dict arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
name="create_recipe",
call_id="call_1",
arguments={"title": "Pasta", "ingredients": ["pasta", "sauce"]},
),
FunctionResultContent(call_id="call_1", result="Created"),
Content.from_function_result(call_id="call_1", result="Created"),
]
)
events = await bridge.from_agent_run_update(update)
@@ -503,14 +504,14 @@ async def test_state_snapshot_after_tool_result():
# Tool call with streaming args
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
# Tool result should trigger StateSnapshotEvent
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
events = await bridge.from_agent_run_update(update2)
# Should have: ToolCallEnd, ToolCallResult, StateSnapshot, ToolCallStart (confirm_changes), ToolCallArgs, ToolCallEnd
@@ -526,12 +527,12 @@ async def test_message_id_persistence_across_chunks():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk
update1 = AgentResponseUpdate(contents=[TextContent(text="Hello ")])
update1 = AgentResponseUpdate(contents=[Content.from_text(text="Hello ")])
events1 = await bridge.from_agent_run_update(update1)
message_id = events1[0].message_id
# Second chunk
update2 = AgentResponseUpdate(contents=[TextContent(text="world")])
update2 = AgentResponseUpdate(contents=[Content.from_text(text="world")])
events2 = await bridge.from_agent_run_update(update2)
# Should use same message_id
@@ -546,14 +547,16 @@ async def test_tool_call_id_tracking():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# First chunk with name
update1 = AgentResponseUpdate(contents=[FunctionCallContent(name="search", call_id="call_1")])
update1 = AgentResponseUpdate(contents=[Content.from_function_call(name="search", call_id="call_1")])
await bridge.from_agent_run_update(update1)
assert bridge.current_tool_call_id == "call_1"
assert bridge.current_tool_call_name == "search"
# Second chunk with args but no name
update2 = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="call_1", arguments='{"q":"AI"}')])
update2 = AgentResponseUpdate(
contents=[Content.from_function_call(name="", call_id="call_1", arguments='{"q":"AI"}')]
)
events2 = await bridge.from_agent_run_update(update2)
# Should still track same tool call
@@ -576,8 +579,8 @@ async def test_tool_name_reset_after_result():
# Tool call
update1 = AgentResponseUpdate(
contents=[
FunctionCallContent(name="write_doc", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"content": "Test"}'),
Content.from_function_call(name="write_doc", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"content": "Test"}'),
]
)
await bridge.from_agent_run_update(update1)
@@ -585,7 +588,7 @@ async def test_tool_name_reset_after_result():
assert bridge.current_tool_call_name == "write_doc"
# Tool result with predictive state (should trigger confirm_changes and reset)
update2 = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")])
update2 = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")])
await bridge.from_agent_run_update(update2)
# Tool name should be reset
@@ -604,9 +607,9 @@ async def test_function_approval_with_wildcard_argument():
},
)
approval_content = FunctionApprovalRequestContent(
approval_content = Content.from_function_approval_request(
id="approval_1",
function_call=FunctionCallContent(
function_call=Content.from_function_call(
name="submit", call_id="call_1", arguments='{"key1": "value1", "key2": "value2"}'
),
)
@@ -632,9 +635,11 @@ async def test_function_approval_missing_argument():
},
)
approval_content = FunctionApprovalRequestContent(
approval_content = Content.from_function_approval_request(
id="approval_1",
function_call=FunctionCallContent(name="process", call_id="call_1", arguments='{"other_field": "value"}'),
function_call=Content.from_function_call(
name="process", call_id="call_1", arguments='{"other_field": "value"}'
),
)
update = AgentResponseUpdate(contents=[approval_content])
@@ -654,8 +659,8 @@ async def test_empty_predict_state_config_no_deltas():
# Tool call with arguments
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
Content.from_function_call(name="search", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
@@ -678,8 +683,8 @@ async def test_tool_with_no_matching_config():
# Tool call for different tool
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="search_web", call_id="call_1"),
FunctionCallContent(name="", call_id="call_1", arguments='{"query": "test"}'),
Content.from_function_call(name="search_web", call_id="call_1"),
Content.from_function_call(name="", call_id="call_1", arguments='{"query": "test"}'),
]
)
events = await bridge.from_agent_run_update(update)
@@ -696,7 +701,7 @@ async def test_tool_call_without_name_or_id():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# This should not crash but log an error
update = AgentResponseUpdate(contents=[FunctionCallContent(name="", call_id="", arguments='{"arg": "val"}')])
update = AgentResponseUpdate(contents=[Content.from_function_call(name="", call_id="", arguments='{"arg": "val"}')])
events = await bridge.from_agent_run_update(update)
# Should emit ToolCallArgsEvent with generated ID
@@ -717,7 +722,7 @@ async def test_state_delta_count_logging():
for i in range(15):
update = AgentResponseUpdate(
contents=[
FunctionCallContent(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
Content.from_function_call(name="", call_id="call_1", arguments=f'{{"text": "Content variation {i}"}}'),
]
)
# Set the tool name to match config
@@ -737,7 +742,7 @@ async def test_tool_result_with_empty_list():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_123", result=[])])
update = AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_123", result=[])])
events = await bridge.from_agent_run_update(update)
assert len(events) == 2
@@ -760,7 +765,7 @@ async def test_tool_result_with_single_text_content():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
contents=[Content.from_function_result(call_id="call_123", result=[MockTextContent("Hello from MCP tool!")])]
)
events = await bridge.from_agent_run_update(update)
@@ -785,7 +790,7 @@ async def test_tool_result_with_multiple_text_contents():
update = AgentResponseUpdate(
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call_123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
@@ -812,7 +817,7 @@ async def test_tool_result_with_model_dump_objects():
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
update = AgentResponseUpdate(
contents=[FunctionResultContent(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
contents=[Content.from_function_result(call_id="call_123", result=[MockModel(value=1), MockModel(value=2)])]
)
events = await bridge.from_agent_run_update(update)
@@ -820,3 +825,93 @@ async def test_tool_result_with_model_dump_objects():
assert events[1].type == "TOOL_CALL_RESULT"
# Should be properly serialized JSON array without double escaping
assert events[1].content == '[{"value": 1}, {"value": 2}]'
async def test_function_call_with_dataclass_arguments():
"""Test FunctionCallContent with dataclass arguments is serialized correctly.
This test verifies the fix for the AG-UI JSON serialization error when
HandoffAgentUserRequest (a dataclass) is passed as FunctionCallContent.arguments.
"""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class TestRequest:
field1: str
field2: int
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# FunctionCallContent with a dataclass as arguments (not a string)
update = AgentResponseUpdate(
contents=[
Content.from_function_call(
name="request_info",
call_id="call_dataclass",
arguments=TestRequest(field1="value", field2=42),
)
]
)
events = await bridge.from_agent_run_update(update)
# Should have ToolCallStartEvent and ToolCallArgsEvent
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
assert len(tool_args_events) == 1
# Verify the delta is valid JSON
delta = tool_args_events[0].delta
parsed = json.loads(delta)
assert parsed == {"field1": "value", "field2": 42}
async def test_function_call_with_nested_dataclass_arguments():
"""Test FunctionCallContent with nested dataclass arguments is serialized correctly.
This test covers the scenario where HandoffAgentUserRequest contains an AgentResponse
with nested content objects.
"""
from dataclasses import dataclass
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@dataclass
class InnerContent:
text: str
@dataclass
class AgentResponseMock:
contents: list[InnerContent]
@dataclass
class HandoffRequest:
agent_response: AgentResponseMock
bridge = AgentFrameworkEventBridge(run_id="test_run", thread_id="test_thread")
# Simulate a HandoffAgentUserRequest-like structure
update = AgentResponseUpdate(
contents=[
Content.from_function_call(
name="request_info",
call_id="call_nested",
arguments=HandoffRequest(
agent_response=AgentResponseMock(contents=[InnerContent(text="Hello from agent")])
),
)
]
)
events = await bridge.from_agent_run_update(update)
# Should have ToolCallStartEvent and ToolCallArgsEvent
tool_args_events = [e for e in events if e.type == "TOOL_CALL_ARGS"]
assert len(tool_args_events) == 1
# Verify the delta is valid JSON and contains nested structure
delta = tool_args_events[0].delta
parsed = json.loads(delta)
assert "agent_response" in parsed
assert parsed["agent_response"]["contents"] == [{"text": "Hello from agent"}]
@@ -2,7 +2,7 @@
"""Tests for human in the loop (function approval requests)."""
from agent_framework import AgentResponseUpdate, FunctionApprovalRequestContent, FunctionCallContent
from agent_framework import AgentResponseUpdate, Content
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -17,12 +17,12 @@ async def test_function_approval_request_emission():
)
# Create approval request
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_123",
name="send_email",
arguments={"to": "user@example.com", "subject": "Test"},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_001",
function_call=func_call,
)
@@ -56,12 +56,12 @@ async def test_function_approval_request_with_confirm_changes():
require_confirmation=True,
)
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_456",
name="delete_file",
arguments={"path": "/tmp/test.txt"},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_002",
function_call=func_call,
)
@@ -109,22 +109,22 @@ async def test_multiple_approval_requests():
require_confirmation=False,
)
func_call_1 = FunctionCallContent(
func_call_1 = Content.from_function_call(
call_id="call_1",
name="create_event",
arguments={"title": "Meeting"},
)
approval_1 = FunctionApprovalRequestContent(
approval_1 = Content.from_function_approval_request(
id="approval_1",
function_call=func_call_1,
)
func_call_2 = FunctionCallContent(
func_call_2 = Content.from_function_call(
call_id="call_2",
name="book_room",
arguments={"room": "Conference A"},
)
approval_2 = FunctionApprovalRequestContent(
approval_2 = Content.from_function_approval_request(
id="approval_2",
function_call=func_call_2,
)
@@ -164,12 +164,12 @@ async def test_function_approval_request_sets_stop_flag():
assert bridge.should_stop_after_confirm is False
func_call = FunctionCallContent(
func_call = Content.from_function_call(
call_id="call_stop_test",
name="get_datetime",
arguments={},
)
approval_request = FunctionApprovalRequestContent(
approval_request = Content.from_function_approval_request(
id="approval_stop_test",
function_call=func_call,
)
@@ -5,7 +5,7 @@
import json
import pytest
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, Role, TextContent
from agent_framework import ChatMessage, Content, Role
from agent_framework_ag_ui._message_adapters import (
agent_framework_messages_to_agui,
@@ -24,7 +24,7 @@ def sample_agui_message():
@pytest.fixture
def sample_agent_framework_message():
"""Create a sample Agent Framework message."""
return ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")], message_id="msg-123")
return ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")], message_id="msg-123")
def test_agui_to_agent_framework_basic(sample_agui_message):
@@ -89,7 +89,7 @@ def test_agui_tool_result_to_agent_framework():
assert message.role == Role.USER
assert len(message.contents) == 1
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert message.contents[0].text == '{"accepted": true, "steps": []}'
assert message.additional_properties is not None
@@ -141,7 +141,7 @@ def test_agui_tool_approval_updates_tool_call_arguments():
assert len(messages) == 2
assistant_msg = messages[0]
func_call = next(content for content in assistant_msg.contents if isinstance(content, FunctionCallContent))
func_call = next(content for content in assistant_msg.contents if content.type == "function_call")
assert func_call.arguments == {
"steps": [
{"description": "Boil water", "status": "enabled"},
@@ -157,11 +157,9 @@ def test_agui_tool_approval_updates_tool_call_arguments():
]
}
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.parse_arguments() == {
"steps": [
@@ -211,12 +209,9 @@ def test_agui_tool_approval_from_confirm_changes_maps_to_function_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -259,12 +254,9 @@ def test_agui_tool_approval_from_confirm_changes_falls_back_to_sibling_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -315,12 +307,9 @@ def test_agui_tool_approval_from_generate_task_steps_maps_to_function_call():
]
messages = agui_messages_to_agent_framework(messages_input)
from agent_framework import FunctionApprovalResponseContent
approval_msg = messages[1]
approval_content = next(
content for content in approval_msg.contents if isinstance(content, FunctionApprovalResponseContent)
content for content in approval_msg.contents if content.type == "function_approval_response"
)
assert approval_content.function_call.call_id == "call_tool"
@@ -380,15 +369,14 @@ def test_agui_function_approvals():
assert msg.role == Role.USER
assert len(msg.contents) == 2
from agent_framework import FunctionApprovalResponseContent
assert isinstance(msg.contents[0], FunctionApprovalResponseContent)
assert msg.contents[0].type == "function_approval_response"
assert msg.contents[0].approved is True
assert msg.contents[0].id == "approval-1"
assert msg.contents[0].function_call.name == "search"
assert msg.contents[0].function_call.call_id == "call-1"
assert isinstance(msg.contents[1], FunctionApprovalResponseContent)
assert msg.contents[1].type == "function_approval_response"
assert msg.contents[1].id == "approval-2"
assert msg.contents[1].approved is False
@@ -406,7 +394,7 @@ def test_agui_non_string_content():
assert len(messages) == 1
assert len(messages[0].contents) == 1
assert isinstance(messages[0].contents[0], TextContent)
assert messages[0].contents[0].type == "text"
assert "nested" in messages[0].contents[0].text
@@ -440,9 +428,9 @@ def test_agui_with_tool_calls_to_agent_framework():
assert msg.role == Role.ASSISTANT
assert msg.message_id == "msg-789"
# First content is text, second is the function call
assert isinstance(msg.contents[0], TextContent)
assert msg.contents[0].type == "text"
assert msg.contents[0].text == "Calling tool"
assert isinstance(msg.contents[1], FunctionCallContent)
assert msg.contents[1].type == "function_call"
assert msg.contents[1].call_id == "call-123"
assert msg.contents[1].name == "get_weather"
assert msg.contents[1].arguments == {"location": "Seattle"}
@@ -453,8 +441,8 @@ def test_agent_framework_to_agui_with_tool_calls():
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[
TextContent(text="Calling tool"),
FunctionCallContent(call_id="call-123", name="search", arguments={"query": "test"}),
Content.from_text(text="Calling tool"),
Content.from_function_call(call_id="call-123", name="search", arguments={"query": "test"}),
],
message_id="msg-456",
)
@@ -477,7 +465,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
"""Test concatenating multiple text contents."""
msg = ChatMessage(
role=Role.ASSISTANT,
contents=[TextContent(text="Part 1 "), TextContent(text="Part 2")],
contents=[Content.from_text(text="Part 1 "), Content.from_text(text="Part 2")],
)
messages = agent_framework_messages_to_agui([msg])
@@ -488,7 +476,7 @@ def test_agent_framework_to_agui_multiple_text_contents():
def test_agent_framework_to_agui_no_message_id():
"""Test message without message_id - should auto-generate ID."""
msg = ChatMessage(role=Role.USER, contents=[TextContent(text="Hello")])
msg = ChatMessage(role=Role.USER, contents=[Content.from_text(text="Hello")])
messages = agent_framework_messages_to_agui([msg])
@@ -500,7 +488,7 @@ def test_agent_framework_to_agui_no_message_id():
def test_agent_framework_to_agui_system_role():
"""Test system role conversion."""
msg = ChatMessage(role=Role.SYSTEM, contents=[TextContent(text="System")])
msg = ChatMessage(role=Role.SYSTEM, contents=[Content.from_text(text="System")])
messages = agent_framework_messages_to_agui([msg])
@@ -510,7 +498,7 @@ def test_agent_framework_to_agui_system_role():
def test_extract_text_from_contents():
"""Test extracting text from contents list."""
contents = [TextContent(text="Hello "), TextContent(text="World")]
contents = [Content.from_text(text="Hello "), Content.from_text(text="World")]
result = extract_text_from_contents(contents)
@@ -533,7 +521,7 @@ class CustomTextContent:
def test_extract_text_from_custom_contents():
"""Test extracting text from custom content objects."""
contents = [CustomTextContent(text="Custom "), TextContent(text="Mixed")]
contents = [CustomTextContent(text="Custom "), Content.from_text(text="Mixed")]
result = extract_text_from_contents(contents)
@@ -547,7 +535,7 @@ def test_agent_framework_to_agui_function_result_dict():
"""Test converting FunctionResultContent with dict result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result={"key": "value", "count": 42})],
contents=[Content.from_function_result(call_id="call-123", result={"key": "value", "count": 42})],
message_id="msg-789",
)
@@ -564,7 +552,7 @@ def test_agent_framework_to_agui_function_result_none():
"""Test converting FunctionResultContent with None result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=None)],
contents=[Content.from_function_result(call_id="call-123", result=None)],
message_id="msg-789",
)
@@ -580,7 +568,7 @@ def test_agent_framework_to_agui_function_result_string():
"""Test converting FunctionResultContent with string result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result="plain text result")],
contents=[Content.from_function_result(call_id="call-123", result="plain text result")],
message_id="msg-789",
)
@@ -595,7 +583,7 @@ def test_agent_framework_to_agui_function_result_empty_list():
"""Test converting FunctionResultContent with empty list result to AG-UI."""
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[])],
contents=[Content.from_function_result(call_id="call-123", result=[])],
message_id="msg-789",
)
@@ -617,7 +605,7 @@ def test_agent_framework_to_agui_function_result_single_text_content():
msg = ChatMessage(
role=Role.TOOL,
contents=[FunctionResultContent(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
contents=[Content.from_function_result(call_id="call-123", result=[MockTextContent("Hello from MCP!")])],
message_id="msg-789",
)
@@ -640,7 +628,7 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
msg = ChatMessage(
role=Role.TOOL,
contents=[
FunctionResultContent(
Content.from_function_result(
call_id="call-123",
result=[MockTextContent("First result"), MockTextContent("Second result")],
)
@@ -654,3 +642,51 @@ def test_agent_framework_to_agui_function_result_multiple_text_contents():
agui_msg = messages[0]
# Multiple items should return JSON array
assert agui_msg["content"] == '["First result", "Second result"]'
def test_agui_tool_approval_with_dataclass_modified_args():
"""Test that agui_messages_to_agent_framework handles dataclass in modified args.
This tests the fix for json.dumps() serialization errors at line 274
when modified_args contains non-serializable objects via make_json_safe.
"""
from dataclasses import dataclass
@dataclass
class ModifiedData:
field1: str
field2: int
# Create AG-UI format messages that simulate tool approval flow
# where modified args could contain a dataclass after parsing
# First, an assistant message with a tool call (string arguments)
assistant_msg = {
"id": "msg-1",
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "call-test",
"type": "function",
"function": {
"name": "update_state",
"arguments": '{"data": "original"}', # String args
},
}
],
}
# Then a user approval message (the approval path will merge modified args)
approval_msg = {
"id": "msg-2",
"role": "user",
"content": '{"approved": true}',
"toolCallId": "call-test",
}
# This should NOT raise TypeError
result = agui_messages_to_agent_framework([assistant_msg, approval_msg])
# Should have processed both messages without error
assert len(result) == 2
@@ -1,6 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import ChatMessage, Content
from agent_framework_ag_ui._message_adapters import _deduplicate_messages, _sanitize_tool_history
@@ -10,7 +10,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_123",
arguments='{"changes": "test"}',
@@ -19,7 +19,7 @@ def test_sanitize_tool_history_injects_confirm_changes_result() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text='{"accepted": true}')],
contents=[Content.from_text(text='{"accepted": true}')],
),
]
@@ -37,11 +37,11 @@ def test_deduplicate_messages_prefers_non_empty_tool_results() -> None:
messages = [
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call1", result="")],
contents=[Content.from_function_result(call_id="call1", result="")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call1", result="result data")],
contents=[Content.from_function_result(call_id="call1", result="result data")],
),
]
@@ -13,8 +13,8 @@ from agent_framework import (
BaseChatClient,
ChatAgent,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
TextContent,
ai_function,
)
@@ -79,11 +79,11 @@ def _create_mock_chat_agent(
if capture_messages is not None:
capture_messages.extend(messages)
yield AgentResponseUpdate(
contents=[TextContent(text="ok")],
contents=[Content.from_text(text="ok")],
role="assistant",
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
raw_representation=ChatResponseUpdate(
contents=[TextContent(text="ok")],
contents=[Content.from_text(text="ok")],
conversation_id=thread.metadata.get("ag_ui_thread_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
response_id=thread.metadata.get("ag_ui_run_id"), # type: ignore[attr-defined] (metadata always created in orchestrator)
),
@@ -253,7 +253,7 @@ async def test_state_context_injected_when_tool_call_state_mismatch() -> None:
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert state_messages
assert "Vegetarian" in state_messages[0]
@@ -302,6 +302,6 @@ async def test_state_context_not_injected_when_tool_call_matches_state() -> None
if role_value != "system":
continue
for content in msg.contents or []:
if isinstance(content, TextContent) and content.text.startswith("Current state of the application:"):
if content.type == "text" and content.text.startswith("Current state of the application:"):
state_messages.append(content.text)
assert not state_messages
@@ -8,12 +8,7 @@ from pathlib import Path
from types import SimpleNamespace
from typing import Any
from agent_framework import (
AgentResponseUpdate,
ChatMessage,
TextContent,
ai_function,
)
from agent_framework import AgentResponseUpdate, ChatMessage, Content, ai_function
from pydantic import BaseModel
from agent_framework_ag_ui._agent import AgentConfig
@@ -48,14 +43,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
messages = [
ChatMessage(
role="tool",
contents=[TextContent(text="not valid json {")],
contents=[Content.from_text(text="not valid json {")],
additional_properties={"is_tool_result": True},
)
]
agent = StubAgent(
default_options={"tools": [approval_tool], "response_format": None},
updates=[AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")],
updates=[AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")],
)
context = TestExecutionContext(
input_data=input_data,
@@ -78,14 +73,14 @@ async def test_human_in_the_loop_json_decode_error() -> None:
async def test_sanitize_tool_history_confirm_changes() -> None:
"""Test sanitize_tool_history logic for confirm_changes synthetic result."""
from agent_framework import ChatMessage, FunctionCallContent, TextContent
from agent_framework import ChatMessage
# Create messages that will trigger confirm_changes synthetic result injection
messages = [
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_123",
arguments='{"changes": "test"}',
@@ -94,7 +89,7 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text='{"accepted": true}')],
contents=[Content.from_text(text='{"accepted": true}')],
),
]
@@ -134,17 +129,17 @@ async def test_sanitize_tool_history_confirm_changes() -> None:
async def test_sanitize_tool_history_orphaned_tool_result() -> None:
"""Test sanitize_tool_history removes orphaned tool results."""
from agent_framework import ChatMessage, FunctionResultContent, TextContent
from agent_framework import ChatMessage
# Tool result without preceding assistant tool call
messages = [
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="orphan_123", result="orphaned data")],
contents=[Content.from_function_result(call_id="orphan_123", result="orphaned data")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
),
]
@@ -214,20 +209,20 @@ async def test_orphaned_tool_result_sanitization() -> None:
async def test_deduplicate_messages_empty_tool_results() -> None:
"""Test deduplicate_messages prefers non-empty tool results."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_789", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_789", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_789", result="")],
contents=[Content.from_function_result(call_id="call_789", result="")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_789", result="real data")],
contents=[Content.from_function_result(call_id="call_789", result="real data")],
),
]
@@ -259,20 +254,20 @@ async def test_deduplicate_messages_empty_tool_results() -> None:
async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
"""Test deduplicate_messages removes duplicate assistant tool call messages."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
),
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="test_tool", call_id="call_abc", arguments="{}")],
contents=[Content.from_function_call(name="test_tool", call_id="call_abc", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_abc", result="result")],
contents=[Content.from_function_result(call_id="call_abc", result="result")],
),
]
@@ -303,20 +298,20 @@ async def test_deduplicate_messages_duplicate_assistant_tool_calls() -> None:
async def test_deduplicate_messages_duplicate_system_messages() -> None:
"""Test that deduplication logic is invoked for system messages."""
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="system",
contents=[TextContent(text="You are a helpful assistant.")],
contents=[Content.from_text(text="You are a helpful assistant.")],
),
ChatMessage(
role="system",
contents=[TextContent(text="You are a helpful assistant.")],
contents=[Content.from_text(text="You are a helpful assistant.")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Hello")],
contents=[Content.from_text(text="Hello")],
),
]
@@ -387,20 +382,20 @@ async def test_state_context_injection() -> None:
async def test_state_context_injection_with_tool_calls_and_input_state() -> None:
"""Test state context is injected when state is provided, even with tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="get_weather", call_id="call_xyz", arguments="{}")],
contents=[Content.from_function_call(name="get_weather", call_id="call_xyz", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_xyz", result="sunny")],
contents=[Content.from_function_result(call_id="call_xyz", result="sunny")],
),
ChatMessage(
role="user",
contents=[TextContent(text="Thanks")],
contents=[Content.from_text(text="Thanks")],
),
]
@@ -452,7 +447,7 @@ async def test_structured_output_processing() -> None:
default_options=DEFAULT_OPTIONS,
updates=[
AgentResponseUpdate(
contents=[TextContent(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
contents=[Content.from_text(text='{"ingredients": ["tomato"], "message": "Added tomato"}')],
role="assistant",
)
],
@@ -641,13 +636,13 @@ async def test_all_messages_filtered_handling() -> None:
async def test_confirm_changes_with_invalid_json_fallback() -> None:
"""Test confirm_changes with invalid JSON falls back to normal processing."""
from agent_framework import ChatMessage, FunctionCallContent, TextContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[
FunctionCallContent(
Content.from_function_call(
name="confirm_changes",
call_id="call_confirm_invalid",
arguments='{"changes": "test"}',
@@ -656,7 +651,7 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
),
ChatMessage(
role="user",
contents=[TextContent(text="invalid json {")],
contents=[Content.from_text(text="invalid json {")],
),
]
@@ -688,19 +683,18 @@ async def test_confirm_changes_with_invalid_json_fallback() -> None:
async def test_confirm_changes_closes_active_message_before_finish() -> None:
"""Confirm-changes flow closes any active text message before run finishes."""
from ag_ui.core import TextMessageEndEvent, TextMessageStartEvent
from agent_framework import FunctionCallContent, FunctionResultContent
updates = [
AgentResponseUpdate(
contents=[
FunctionCallContent(
Content.from_function_call(
name="write_document_local",
call_id="call_1",
arguments='{"document": "Draft"}',
)
]
),
AgentResponseUpdate(contents=[FunctionResultContent(call_id="call_1", result="Done")]),
AgentResponseUpdate(contents=[Content.from_function_result(call_id="call_1", result="Done")]),
]
orchestrator = DefaultOrchestrator()
@@ -735,16 +729,16 @@ async def test_confirm_changes_closes_active_message_before_finish() -> None:
async def test_tool_result_kept_when_call_id_matches() -> None:
"""Test tool result is kept when call_id matches pending tool calls."""
from agent_framework import ChatMessage, FunctionCallContent, FunctionResultContent
from agent_framework import ChatMessage
messages = [
ChatMessage(
role="assistant",
contents=[FunctionCallContent(name="get_data", call_id="call_match", arguments="{}")],
contents=[Content.from_function_call(name="get_data", call_id="call_match", arguments="{}")],
),
ChatMessage(
role="tool",
contents=[FunctionResultContent(call_id="call_match", result="data")],
contents=[Content.from_function_result(call_id="call_match", result="data")],
),
]
@@ -794,11 +788,11 @@ async def test_agent_protocol_fallback_paths() -> None:
**kwargs: Any,
) -> AsyncGenerator[AgentResponseUpdate, None]:
self.messages_received = messages
yield AgentResponseUpdate(contents=[TextContent(text="response")], role="assistant")
yield AgentResponseUpdate(contents=[Content.from_text(text="response")], role="assistant")
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
@@ -820,9 +814,9 @@ async def test_agent_protocol_fallback_paths() -> None:
async def test_initial_state_snapshot_with_array_schema() -> None:
"""Test state initialization with array type schema."""
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": [], "state": {}}
@@ -851,9 +845,9 @@ async def test_response_format_skip_text_content() -> None:
class OutputModel(BaseModel):
result: str
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
messages = [ChatMessage(role="user", contents=[TextContent(text="Hello")])]
messages = [ChatMessage(role="user", contents=[Content.from_text(text="Hello")])]
orchestrator = DefaultOrchestrator()
input_data: dict[str, Any] = {"messages": []}
@@ -876,3 +870,60 @@ async def test_response_format_skip_text_content() -> None:
# Test passes if no errors occur - verifies response_format code path
assert len(events) > 0
async def test_human_in_the_loop_handles_none_additional_properties() -> None:
"""Test that HumanInTheLoopOrchestrator handles None additional_properties gracefully.
This test ensures the null safety fix for msg.additional_properties.get() works.
"""
orchestrator = HumanInTheLoopOrchestrator()
# Create a message with None additional_properties
msg = ChatMessage(
role="user",
contents=[Content.from_text(text="Hello")],
)
# Explicitly set additional_properties to None
msg.additional_properties = None # type: ignore[assignment]
agent = StubAgent() # Use default StubAgent
config = AgentConfig()
context = TestExecutionContext(
input_data={"messages": [{"role": "user", "content": "Hello"}]},
agent=agent,
config=config,
)
context.set_messages([msg])
# can_handle should return False (not crash) when additional_properties is None
result = orchestrator.can_handle(context)
assert result is False
async def test_default_orchestrator_handles_none_default_options() -> None:
"""Test that DefaultOrchestrator handles None default_options gracefully.
This test ensures the null safety fix for context.agent.default_options.get() works.
"""
orchestrator = DefaultOrchestrator()
# Use StubAgent with default_options set to None
agent = StubAgent(default_options=None)
config = AgentConfig()
context = TestExecutionContext(
input_data={"messages": [{"role": "user", "content": "Hello"}]},
agent=agent,
config=config,
)
# This should NOT crash when accessing default_options.get()
events: list[Any] = []
async for event in orchestrator.run(context):
events.append(event)
# Just check a few events to verify it's working
if len(events) > 3:
break
# Test passes if no AttributeError occurred
assert True
@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
from ag_ui.core import RunFinishedEvent, RunStartedEvent
from agent_framework import TextContent
from agent_framework import Content
from agent_framework._types import AgentResponseUpdate, ChatResponseUpdate
sys.path.insert(0, str(Path(__file__).parent))
@@ -20,10 +20,10 @@ async def test_service_thread_id_when_there_are_updates():
updates: list[AgentResponseUpdate] = [
AgentResponseUpdate(
contents=[TextContent(text="Hello, user!")],
contents=[Content.from_text(text="Hello, user!")],
response_id="resp_67890",
raw_representation=ChatResponseUpdate(
contents=[TextContent(text="Hello, user!")],
contents=[Content.from_text(text="Hello, user!")],
conversation_id="conv_12345",
response_id="resp_67890",
),
@@ -8,7 +8,7 @@ from typing import Any
import pytest
from ag_ui.core import StateSnapshotEvent
from agent_framework import ChatAgent, ChatResponseUpdate, TextContent
from agent_framework import ChatAgent, ChatResponseUpdate, Content
from agent_framework_ag_ui._agent import AgentFrameworkAgent
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
@@ -20,7 +20,7 @@ from utils_test_ag_ui import StreamingChatClientStub, stream_from_updates
@pytest.fixture
def mock_agent() -> ChatAgent:
"""Create a mock agent for testing."""
updates = [ChatResponseUpdate(contents=[TextContent(text="Hello!")])]
updates = [ChatResponseUpdate(contents=[Content.from_text(text="Hello!")])]
chat_client = StreamingChatClientStub(stream_from_updates(updates))
return ChatAgent(name="test_agent", instructions="Test agent", chat_client=chat_client)
@@ -1,7 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from ag_ui.core import CustomEvent, EventType
from agent_framework import ChatMessage, TextContent
from agent_framework import ChatMessage
from agent_framework_ag_ui._events import AgentFrameworkEventBridge
from agent_framework_ag_ui._orchestration._state_manager import StateManager
@@ -47,5 +47,59 @@ def test_state_context_only_when_new_user_turn() -> None:
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
assert isinstance(message, ChatMessage)
assert isinstance(message.contents[0], TextContent)
assert message.contents[0].type == "text"
assert "Current state of the application" in message.contents[0].text
def test_state_manager_with_dataclass_in_state() -> None:
"""Test that state containing dataclasses can be serialized without crashing.
This test ensures the fix for JSON serialization errors when state
contains dataclass or other non-JSON-serializable objects.
"""
from dataclasses import dataclass
@dataclass
class UserData:
name: str
age: int
state_manager = StateManager(
state_schema={"user": {"type": "object"}},
predict_state_config=None,
require_confirmation=True,
)
# Initialize with a dataclass object in the state
state_manager.initialize({"user": UserData(name="Alice", age=30)})
# This should NOT raise TypeError when generating the context message
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
assert message is not None
assert isinstance(message, ChatMessage)
# The dataclass should be serialized to JSON in the message
assert "Alice" in message.contents[0].text
assert "30" in message.contents[0].text
def test_state_manager_with_pydantic_in_state() -> None:
"""Test that state containing Pydantic models can be serialized without crashing."""
from pydantic import BaseModel
class UserModel(BaseModel):
email: str
active: bool
state_manager = StateManager(
state_schema={"user": {"type": "object"}},
predict_state_config=None,
require_confirmation=True,
)
# Initialize with a Pydantic model in the state
state_manager.initialize({"user": UserModel(email="test@example.com", active=True)})
# This should NOT raise TypeError
message = state_manager.state_context_message(is_new_user_turn=True, conversation_has_tool_calls=False)
assert message is not None
assert "test@example.com" in message.contents[0].text

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