Compare commits

...
Author SHA1 Message Date
Evan MattsonandGitHub 6adcac2e97 Python: Flow custom kwargs to agents via Workflow SharedState (#2894)
* Flow custom kwargs to agents via SharedState

* Address Copilot feedback

* Improve sample typing

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

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

* Add tests and improve comments

* Fix mypy

* Simplify handoff_simple.py

* Simplify handoff_autonoumous.py and bug fix

* Update readme

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

* Add support for deprecated `run_agent`.

* Fix entity method name.

* Fix method name and improve tests.

* Update comment.

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

* Try to disable false positive naming warning.

* Add comment about disabled warnings.

* Rename `RunAgent` to just `Run`.

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

* added tests

* small fix

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

* Remove unnecessary async

* PR feedback: clarify UTC

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

* PR feedback: Fix TTL disablement

* Linter: use auto-property

* Fix build break from OpenAI SDK change

* Updated CHANGELOG.md

* PR feedback

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

* Force a CosmosDB source code change to trigger the pipeline

* Address possible string boolean mismatch

* Add debug

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

* Added Olama Sample

* Add Sample & Fixed Open Telemetry

* Fixed Spelling from Olama to Ollama

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

* Added Tool Calling

* Finalizing test cases

* Adjust samples to be more reliable

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

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

* Update python/packages/ollama/pyproject.toml

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

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

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

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

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

* Improved Docstrings & Sample

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

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

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

* Revert setting, so it can be none

* Validate Message formatting between AF and Ollama

* Catch Ollama Error and raise a ServiceResponse Error

* Fix mypy error

* remove .vscode comma

* Add Reasoning support & adjust to new structure

* Add Ollama Multimodality and Reasoning

* Add test cases for reasoning

* Add Tests for Error Handling in Ollama Client

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

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

* Integrated Copilot Feedback

* Implement first PR Feedback

* Adjust Readme files for examples

* Adjust argument passing via additional chat options

* Implemented PR Feedback

* Removing Ollama Package from Core and moving samples

* Fix Link & Adding Samples to Main Sample Readme

* Fixing Links in Readme

* Moved Multimodal and Chat Example

* Fixed Link in ChatClient to Ollama

* Fix AgentFramework Links in Ollama Project

* Fix observability breaking change

---------

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

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

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

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

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

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

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

Absorb breaking changes in Responses surface area

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

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

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

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

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

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

---------

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

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

* Eliminating duck typing

* Improve function exec result handling

* Fix race condition

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

* WIP on updates using configure_azure_monitor

* improved setup and clarity

* fixed root .env.example

* revert changes

* updated files

* updated sample

* updated zero code

* test fixes and fixed links

* fix devui

* removed planning docs

* added enable method and updated readme and samples

* clarified docstring

* add return annotation

* updated naming

* update capatilized version

* updated readme and some fixes

* updated decorator name inline with the rest

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

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

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

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

Fixes #2713

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

* use raw_representation

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

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

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

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

* Added thread object to ai_function kwargs

* Updated docs

* Small fix

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

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

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

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

* Unify label in EdgeData

* Edge API adjustments, removed useless "sanitizer"

* fixed test

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-12-12 00:31:45 +00:00
187 changed files with 12733 additions and 5622 deletions
@@ -839,7 +839,7 @@ var agentOptions = new ChatClientAgentRunOptions(new ChatOptions
{
MaxOutputTokens = 8000,
// Breaking glass to access provider-specific options
RawRepresentationFactory = (_) => new OpenAI.Responses.ResponseCreationOptions()
RawRepresentationFactory = (_) => new OpenAI.Responses.CreateResponseOptions()
{
ReasoningOptions = new()
{
+19 -13
View File
@@ -35,7 +35,8 @@ jobs:
contents: read
pull-requests: read
outputs:
dotnetChanges: ${{ steps.filter.outputs.dotnet}}
dotnetChanges: ${{ steps.filter.outputs.dotnet }}
cosmosDbChanges: ${{ steps.filter.outputs.cosmosdb }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
@@ -44,10 +45,15 @@ jobs:
filters: |
dotnet:
- 'dotnet/**'
cosmosdb:
- 'dotnet/src/Microsoft.Agents.AI.CosmosNoSql/**'
# run only if 'dotnet' files were changed
- name: dotnet tests
if: steps.filter.outputs.dotnet == 'true'
run: echo "Dotnet file"
- name: dotnet CosmosDB tests
if: steps.filter.outputs.cosmosdb == 'true'
run: echo "Dotnet CosmosDB changes"
# run only if not 'dotnet' files were changed
- name: not dotnet tests
if: steps.filter.outputs.dotnet != 'true'
@@ -77,6 +83,16 @@ jobs:
dotnet
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.0.1
@@ -123,17 +139,7 @@ jobs:
popd
popd
rm -rf "$TEMP_DIR"
# Start Cosmos DB Emulator for Cosmos-based unit tests (only on Windows)
- name: Start Azure Cosmos DB Emulator
if: runner.os == 'Windows'
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Run Unit Tests
shell: bash
run: |
@@ -225,7 +231,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
- uses: actions/cache@v4
- uses: actions/cache@v5
with:
path: ~/.cache/pre-commit
key: pre-commit|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
@@ -21,7 +21,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Download coverage report
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
run-id: ${{ github.event.workflow_run.id }}
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- name: Run all tests with coverage report
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
- name: Upload coverage report
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
path: |
python/python-coverage.xml
+5 -5
View File
@@ -10,19 +10,19 @@ model:
temperature: 0.9
topP: 0.95
connection:
kind: ApiKey
key: =Env.OPENAI_API_KEY
kind: key
apiKey: =Env.OPENAI_APIKEY
outputSchema:
properties:
language:
type: string
kind: string
required: true
description: The language of the answer.
answer:
type: string
kind: string
required: true
description: The answer text.
type:
type: string
kind: string
required: true
description: The type of the response.
@@ -0,0 +1,147 @@
# Time-To-Live (TTL) for durable agent sessions
## Overview
The durable agents automatically maintain conversation history and state for each session. Without automatic cleanup, this state can accumulate indefinitely, consuming storage resources and increasing costs. The Time-To-Live (TTL) feature provides automatic cleanup of idle agent sessions, ensuring that sessions are automatically deleted after a period of inactivity.
## What is TTL?
Time-To-Live (TTL) is a configurable duration that determines how long an agent session state will be retained after its last interaction. When an agent session is idle (no messages sent to it) for longer than the TTL period, the session state is automatically deleted. Each new interaction with an agent resets the TTL timer, extending the session's lifetime.
## Benefits
- **Automatic cleanup**: No manual intervention required to clean up idle agent sessions
- **Cost optimization**: Reduces storage costs by automatically removing unused session state
- **Resource management**: Prevents unbounded growth of agent session state in storage
- **Configurable**: Set TTL globally or per-agent type to match your application's needs
## Configuration
TTL can be configured at two levels:
1. **Global default TTL**: Applies to all agent sessions unless overridden
2. **Per-agent type TTL**: Overrides the global default for specific agent types
Additionally, you can configure a **minimum deletion delay** that controls how frequently deletion operations are scheduled. The default value is 5 minutes, and the maximum allowed value is also 5 minutes.
> [!NOTE]
> Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions. However, this can also increase the load on the system and should be used with caution.
### Default values
- **Default TTL**: 14 days
- **Minimum TTL deletion delay**: 5 minutes (maximum allowed value, subject to change in future releases)
### Configuration examples
#### .NET
```csharp
// Configure global default TTL and minimum signal delay
services.ConfigureDurableAgents(
options =>
{
// Set global default TTL to 7 days
options.DefaultTimeToLive = TimeSpan.FromDays(7);
// Add agents (will use global default TTL)
options.AddAIAgent(myAgent);
});
// Configure per-agent TTL
services.ConfigureDurableAgents(
options =>
{
options.DefaultTimeToLive = TimeSpan.FromDays(14); // Global default
// Agent with custom TTL of 1 day
options.AddAIAgent(shortLivedAgent, timeToLive: TimeSpan.FromDays(1));
// Agent with custom TTL of 90 days
options.AddAIAgent(longLivedAgent, timeToLive: TimeSpan.FromDays(90));
// Agent using global default (14 days)
options.AddAIAgent(defaultAgent);
});
// Disable TTL for specific agents by setting TTL to null
services.ConfigureDurableAgents(
options =>
{
options.DefaultTimeToLive = TimeSpan.FromDays(14);
// Agent with no TTL (never expires)
options.AddAIAgent(permanentAgent, timeToLive: null);
});
```
## How TTL works
The following sections describe how TTL works in detail.
### Expiration tracking
Each agent session maintains an expiration timestamp in its internally managed state that is updated whenever the session processes a message:
1. When a message is sent to an agent session, the expiration time is set to `current time + TTL`
2. The runtime schedules a delete operation for the expiration time (subject to minimum delay constraints)
3. When the delete operation runs, if the current time is past the expiration time, the session state is deleted. Otherwise, the delete operation is rescheduled for the next expiration time.
### State deletion
When an agent session expires, its entire state is deleted, including:
- Conversation history
- Any custom state data
- Expiration timestamps
After deletion, if a message is sent to the same agent session, a new session is created with a fresh conversation history.
## Behavior examples
The following examples illustrate how TTL works in different scenarios.
### Example 1: Agent session expires after TTL
1. Agent configured with 30-day TTL
2. User sends message at Day 0 → agent session created, expiration set to Day 30
3. No further messages sent
4. At Day 30 → Agent session is deleted
5. User sends message at Day 31 → New agent session created with fresh conversation history
### Example 2: TTL reset on interaction
1. Agent configured with 30-day TTL
2. User sends message at Day 0 → agent session created, expiration set to Day 30
3. User sends message at Day 15 → Expiration reset to Day 45
4. User sends message at Day 40 → Expiration reset to Day 70
5. Agent session remains active as long as there are regular interactions
## Logging
The TTL feature includes comprehensive logging to track state changes:
- **Expiration time updated**: Logged when TTL expiration time is set or updated
- **Deletion scheduled**: Logged when a deletion check signal is scheduled
- **Deletion check**: Logged when a deletion check operation runs
- **Session expired**: Logged when an agent session is deleted due to expiration
- **TTL rescheduled**: Logged when a deletion signal is rescheduled
These logs help monitor TTL behavior and troubleshoot any issues.
## Best practices
1. **Choose appropriate TTL values**: Balance between storage costs and user experience. Too short TTLs may delete active sessions, while too long TTLs may accumulate unnecessary state.
2. **Use per-agent TTLs**: Different agents may have different usage patterns. Configure TTLs per-agent based on expected session lifetimes.
3. **Monitor expiration logs**: Review logs to understand TTL behavior and adjust configuration as needed.
4. **Test with short TTLs**: During development, use short TTLs (e.g., minutes) to verify TTL behavior without waiting for long periods.
## Limitations
- TTL is based on wall-clock time, not activity time. The expiration timer starts from the last message timestamp.
- Deletion checks are durably scheduled operations and may have slight delays depending on system load.
- Once an agent session is deleted, its conversation history cannot be recovered.
- TTL deletion requires at least one worker to be available to process the deletion operation message.
+1
View File
@@ -209,6 +209,7 @@ dotnet_diagnostic.CA2000.severity = none # Call System.IDisposable.Dispose on ob
dotnet_diagnostic.CA2225.severity = none # Operator overloads have named alternates
dotnet_diagnostic.CA2227.severity = none # Change to be read-only by removing the property setter
dotnet_diagnostic.CA2249.severity = suggestion # Consider using 'Contains' method instead of 'IndexOf' method
dotnet_diagnostic.CA2252.severity = none # Requires preview
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
dotnet_diagnostic.CA2253.severity = none # Named placeholders in the logging message template should not be comprised of only numeric characters
dotnet_diagnostic.CA2263.severity = suggestion # Use generic overload
+7 -9
View File
@@ -19,10 +19,10 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.0.0-beta.440" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.3" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageVersion Include="Azure.AI.Projects.OpenAI" Version="1.0.0-beta.5" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.8" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.1" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
<!-- Google Gemini -->
@@ -61,10 +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.0" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="10.0.0-preview.1.25559.3" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
<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.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" />
@@ -101,11 +100,10 @@
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.4.0-preview.3" />
<!-- Inference SDKs -->
<PackageVersion Include="Anthropic.SDK" Version="5.8.0" />
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.11" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.7.0" />
<PackageVersion Include="OpenAI" Version="2.8.0" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
@@ -25,7 +25,6 @@
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.AspNetCore.OpenAPI" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" />
@@ -1,8 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using AgentWebChat.AgentHost.Utilities;
using Azure;
using Azure.AI.Inference;
using Microsoft.Extensions.AI;
using OllamaSharp;
@@ -24,7 +22,6 @@ public static class ChatClientExtensions
ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
@@ -44,16 +41,6 @@ public static class ChatClientExtensions
})
.AddChatClient(connectionInfo.SelectedModel);
private static ChatClientBuilder AddAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
builder.Services.AddChatClient(sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
@@ -83,7 +70,6 @@ public static class ChatClientExtensions
ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddKeyedAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
@@ -103,16 +89,6 @@ public static class ChatClientExtensions
})
.AddKeyedChatClient(connectionName, connectionInfo.SelectedModel);
private static ChatClientBuilder AddKeyedAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo) =>
builder.Services.AddKeyedChatClient(connectionName, sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
@@ -27,7 +27,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC
Transport = new HttpClientPipelineTransport(httpClient)
};
var openAiClient = new OpenAIResponseClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var openAiClient = new ResponsesClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient();
var chatOptions = new ChatOptions()
{
ConversationId = threadId
@@ -32,6 +32,6 @@ AIAgent agent = client.GetChatClient(deploymentName).CreateAIAgent(JokerInstruct
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableAgents(options => options.AddAIAgent(agent))
.ConfigureDurableAgents(options => options.AddAIAgent(agent, timeToLive: TimeSpan.FromHours(1)))
.Build();
app.Run();
@@ -13,7 +13,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
@@ -11,7 +11,7 @@ var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
AIAgent agent = new OpenAIClient(
apiKey)
.GetOpenAIResponseClient(model)
.GetResponsesClient(model)
.CreateAIAgent(instructions: "You are good at telling jokes.", name: "Joker");
// Invoke the agent and output the text result.
@@ -11,11 +11,11 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new I
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5";
var client = new OpenAIClient(apiKey)
.GetOpenAIResponseClient(model)
.GetResponsesClient(model)
.AsIChatClient().AsBuilder()
.ConfigureOptions(o =>
{
o.RawRepresentationFactory = _ => new ResponseCreationOptions()
o.RawRepresentationFactory = _ => new CreateResponseOptions()
{
ReasoningOptions = new()
{
@@ -16,13 +16,13 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <summary>
/// Initialize an instance of <see cref="OpenAIResponseClientAgent"/>.
/// </summary>
/// <param name="client">Instance of <see cref="OpenAIResponseClient"/></param>
/// <param name="client">Instance of <see cref="ResponsesClient"/></param>
/// <param name="instructions">Optional instructions for the agent.</param>
/// <param name="name">Optional name for the agent.</param>
/// <param name="description">Optional description for the agent.</param>
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
public OpenAIResponseClientAgent(
OpenAIResponseClient client,
ResponsesClient client,
string? instructions = null,
string? name = null,
string? description = null,
@@ -39,11 +39,11 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <summary>
/// Initialize an instance of <see cref="OpenAIResponseClientAgent"/>.
/// </summary>
/// <param name="client">Instance of <see cref="OpenAIResponseClient"/></param>
/// <param name="client">Instance of <see cref="ResponsesClient"/></param>
/// <param name="options">Options to create the agent.</param>
/// <param name="loggerFactory">Optional instance of <see cref="ILoggerFactory"/></param>
public OpenAIResponseClientAgent(
OpenAIResponseClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) :
ResponsesClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) :
base(new ChatClientAgent((client ?? throw new ArgumentNullException(nameof(client))).AsIChatClient(), options, loggerFactory))
{
}
@@ -55,8 +55,8 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="OpenAIResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async Task<OpenAIResponse> RunAsync(
/// <returns>A <see cref="ResponseResult"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async Task<ResponseResult> RunAsync(
IEnumerable<ResponseItem> messages,
AgentThread? thread = null,
AgentRunOptions? options = null,
@@ -74,7 +74,7 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="OpenAIResponse"/> containing the list of <see cref="ChatMessage"/> items.</returns>
/// <returns>A <see cref="ResponseResult"/> containing the list of <see cref="ChatMessage"/> items.</returns>
public virtual async IAsyncEnumerable<StreamingResponseUpdate> RunStreamingAsync(
IEnumerable<ResponseItem> messages,
AgentThread? thread = null,
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to create OpenAIResponseClientAgent directly from an OpenAIResponseClient instance.
// This sample demonstrates how to create OpenAIResponseClientAgent directly from an ResponsesClient instance.
using OpenAI;
using OpenAI.Responses;
@@ -9,16 +9,16 @@ using OpenAIResponseClientSample;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set.");
var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-mini";
// Create an OpenAIResponseClient directly from OpenAIClient
OpenAIResponseClient responseClient = new OpenAIClient(apiKey).GetOpenAIResponseClient(model);
// Create a ResponsesClient directly from OpenAIClient
ResponsesClient responseClient = new OpenAIClient(apiKey).GetResponsesClient(model);
// Create an agent directly from the OpenAIResponseClient using OpenAIResponseClientAgent
// Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent
OpenAIResponseClientAgent agent = new(responseClient, instructions: "You are good at telling jokes.", name: "Joker");
ResponseItem userMessage = ResponseItem.CreateUserMessageItem("Tell me a joke about a pirate.");
// Invoke the agent and output the text result.
OpenAIResponse response = await agent.RunAsync([userMessage]);
ResponseResult response = await agent.RunAsync([userMessage]);
Console.WriteLine(response.GetOutputText());
// Invoke the agent with streaming support.
@@ -21,8 +21,8 @@ string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-4o-min
OpenAIClient openAIClient = new(apiKey);
ConversationClient conversationClient = openAIClient.GetConversationClient();
// Create an agent directly from the OpenAIResponseClient using OpenAIResponseClientAgent
ChatClientAgent agent = new(openAIClient.GetOpenAIResponseClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent");
// Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent
ChatClientAgent agent = new(openAIClient.GetResponsesClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent");
ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}")));
@@ -22,7 +22,7 @@ var stateStore = new Dictionary<string, JsonElement?>();
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
name: "SpaceNovelWriter",
instructions: "You are a space novel writer. Always research relevant facts and generate character profiles for the main characters before writing novels." +
@@ -13,7 +13,7 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent();
// Enable background responses (only supported by OpenAI Responses at this time).
@@ -73,7 +73,7 @@ internal sealed class Program
Dictionary<string, byte[]> screenshots = ComputerUseUtil.LoadScreenshotAssets();
ChatOptions chatOptions = new();
ResponseCreationOptions responseCreationOptions = new()
CreateResponseOptions responseCreationOptions = new()
{
TruncationMode = ResponseTruncationMode.Auto
};
@@ -30,7 +30,7 @@ var mcpTool = new HostedMcpServerTool(
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
@@ -57,7 +57,7 @@ var mcpToolWithApproval = new HostedMcpServerTool(
AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgentWithApproval",
@@ -27,7 +27,7 @@ TokenCredential browserCredential = new InteractiveBrowserCredential(
using IChatClient client = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.GetResponsesClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App"))
@@ -23,11 +23,6 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
private readonly AgentRecord? _agentRecord;
private readonly ChatOptions? _chatOptions;
private readonly AgentReference _agentReference;
/// <summary>
/// The usage of a no-op model is a necessary change to avoid OpenAIClients to throw exceptions when
/// used with Azure AI Agents as the model used is now defined at the agent creation time.
/// </summary>
private const string NoOpModel = "no-op";
/// <summary>
/// Initializes a new instance of the <see cref="AzureAIProjectChatClient"/> class.
@@ -42,7 +37,7 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentReference agentReference, string? defaultModelId, ChatOptions? chatOptions)
: base(Throw.IfNull(aiProjectClient)
.GetProjectOpenAIClient()
.GetOpenAIResponseClient(defaultModelId ?? NoOpModel)
.GetProjectResponsesClientForAgent(agentReference)
.AsIChatClient())
{
this._agentClient = aiProjectClient;
@@ -132,13 +127,15 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient
agentEnabledChatOptions.RawRepresentationFactory = (client) =>
{
if (originalFactory?.Invoke(this) is not ResponseCreationOptions responseCreationOptions)
if (originalFactory?.Invoke(this) is not CreateResponseOptions responseCreationOptions)
{
responseCreationOptions = new ResponseCreationOptions();
responseCreationOptions = new CreateResponseOptions();
}
ResponseCreationOptionsExtensions.set_Agent(responseCreationOptions, this._agentReference);
ResponseCreationOptionsExtensions.set_Model(responseCreationOptions, null);
responseCreationOptions.Agent = this._agentReference;
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
responseCreationOptions.Patch.Remove("$.model"u8);
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
return responseCreationOptions;
};
@@ -400,7 +400,7 @@ public static partial class AzureAIProjectChatClientExtensions
};
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
@@ -466,7 +466,7 @@ public static partial class AzureAIProjectChatClientExtensions
};
// Attempt to capture breaking glass options from the raw representation factory that match the agent definition.
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is ResponseCreationOptions respCreationOptions)
if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions)
{
agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions;
}
@@ -217,9 +217,7 @@ public class CosmosCheckpointStore<T> : JsonCheckpointStore, IDisposable
}
}
/// <summary>
/// Represents a checkpoint document stored in Cosmos DB.
/// </summary>
/// <summary>Represents a checkpoint document stored in Cosmos DB.</summary>
internal sealed class CosmosCheckpointDocument
{
[JsonProperty("id")]
@@ -16,29 +16,34 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
private readonly DurableTaskClient _client = services.GetRequiredService<DurableTaskClient>();
private readonly ILoggerFactory _loggerFactory = services.GetRequiredService<ILoggerFactory>();
private readonly IAgentResponseHandler? _messageHandler = services.GetService<IAgentResponseHandler>();
private readonly DurableAgentsOptions _options = services.GetRequiredService<DurableAgentsOptions>();
private readonly CancellationToken _cancellationToken = cancellationToken != default
? cancellationToken
: services.GetService<IHostApplicationLifetime>()?.ApplicationStopping ?? CancellationToken.None;
public async Task<AgentRunResponse> RunAgentAsync(RunRequest request)
public Task<AgentRunResponse> RunAgentAsync(RunRequest request)
{
return this.Run(request);
}
// IDE1006 and VSTHRD200 disabled to allow method name to match the common cross-platform entity operation name.
#pragma warning disable IDE1006
#pragma warning disable VSTHRD200
public async Task<AgentRunResponse> Run(RunRequest request)
#pragma warning restore VSTHRD200
#pragma warning restore IDE1006
{
AgentSessionId sessionId = this.Context.Id;
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
}
AIAgent agent = agentFactory(this._services);
AIAgent agent = this.GetAgent(sessionId);
EntityAgentWrapper agentWrapper = new(agent, this.Context, request, this._services);
// Logger category is Microsoft.DurableTask.Agents.{agentName}.{sessionId}
ILogger logger = this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agent.Name}.{sessionId.Key}");
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
if (request.Messages.Count == 0)
{
logger.LogInformation("Ignoring empty request");
return new AgentRunResponse();
}
this.State.Data.ConversationHistory.Add(DurableAgentStateRequest.FromRunRequest(request));
@@ -113,6 +118,36 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
response.Usage?.TotalTokenCount);
}
// Update TTL expiration time. Only schedule deletion check on first interaction.
// Subsequent interactions just update the expiration time; CheckAndDeleteIfExpiredAsync
// will reschedule the deletion check when it runs.
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
if (timeToLive.HasValue)
{
DateTime newExpirationTime = DateTime.UtcNow.Add(timeToLive.Value);
bool isFirstInteraction = this.State.Data.ExpirationTimeUtc is null;
this.State.Data.ExpirationTimeUtc = newExpirationTime;
logger.LogTTLExpirationTimeUpdated(sessionId, newExpirationTime);
// Only schedule deletion check on the first interaction when entity is created.
// On subsequent interactions, we just update the expiration time. The scheduled
// CheckAndDeleteIfExpiredAsync will reschedule itself if the entity hasn't expired.
if (isFirstInteraction)
{
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
}
}
else
{
// TTL is disabled. Clear the expiration time if it was previously set.
if (this.State.Data.ExpirationTimeUtc.HasValue)
{
logger.LogTTLExpirationTimeCleared(sessionId);
this.State.Data.ExpirationTimeUtc = null;
}
}
return response;
}
finally
@@ -121,4 +156,78 @@ internal class AgentEntity(IServiceProvider services, CancellationToken cancella
DurableAgentContext.ClearCurrent();
}
}
/// <summary>
/// Checks if the entity has expired and deletes it if so, otherwise reschedules the deletion check.
/// </summary>
/// <remarks>
/// This method is called by the durable task runtime when a <c>CheckAndDeleteIfExpired</c> signal is received.
/// </remarks>
public void CheckAndDeleteIfExpired()
{
AgentSessionId sessionId = this.Context.Id;
AIAgent agent = this.GetAgent(sessionId);
ILogger logger = this.GetLogger(agent.Name!, sessionId.Key);
DateTime currentTime = DateTime.UtcNow;
DateTime? expirationTime = this.State.Data.ExpirationTimeUtc;
logger.LogTTLDeletionCheck(sessionId, expirationTime, currentTime);
if (expirationTime.HasValue)
{
if (currentTime >= expirationTime.Value)
{
// Entity has expired, delete it
logger.LogTTLEntityExpired(sessionId, expirationTime.Value);
this.State = null!;
}
else
{
// Entity hasn't expired yet, reschedule the deletion check
TimeSpan? timeToLive = this._options.GetTimeToLive(sessionId.Name);
if (timeToLive.HasValue)
{
this.ScheduleDeletionCheck(sessionId, logger, timeToLive.Value);
}
}
}
}
private void ScheduleDeletionCheck(AgentSessionId sessionId, ILogger logger, TimeSpan timeToLive)
{
DateTime currentTime = DateTime.UtcNow;
DateTime expirationTime = this.State.Data.ExpirationTimeUtc ?? currentTime.Add(timeToLive);
TimeSpan minimumDelay = this._options.MinimumTimeToLiveSignalDelay;
// To avoid excessive scheduling, we schedule the deletion check for no less than the minimum delay.
DateTime scheduledTime = expirationTime > currentTime.Add(minimumDelay)
? expirationTime
: currentTime.Add(minimumDelay);
logger.LogTTLDeletionScheduled(sessionId, scheduledTime);
// Schedule a signal to self to check for expiration
this.Context.SignalEntity(
this.Context.Id,
nameof(CheckAndDeleteIfExpired), // self-signal
options: new SignalEntityOptions { SignalTime = scheduledTime });
}
private AIAgent GetAgent(AgentSessionId sessionId)
{
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents =
this._services.GetRequiredService<IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>>>();
if (!agents.TryGetValue(sessionId.Name, out Func<IServiceProvider, AIAgent>? agentFactory))
{
throw new InvalidOperationException($"Agent '{sessionId.Name}' not found");
}
return agentFactory(this._services);
}
private ILogger GetLogger(string agentName, string sessionKey)
{
return this._loggerFactory.CreateLogger($"Microsoft.DurableTask.Agents.{agentName}.{sessionKey}");
}
}
@@ -1,5 +1,12 @@
# Release History
## [Unreleased]
### Changed
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
## v1.0.0-preview.251204.1
- Added orchestration ID to durable agent entity state ([#2137](https://github.com/microsoft/agent-framework/pull/2137))
@@ -22,7 +22,7 @@ internal class DefaultDurableAgentClient(DurableTaskClient client, ILoggerFactor
await this._client.Entities.SignalEntityAsync(
sessionId,
nameof(AgentEntity.RunAgentAsync),
nameof(AgentEntity.Run),
request,
cancellation: cancellationToken);
@@ -107,7 +107,7 @@ public sealed class DurableAIAgent : AIAgent
{
return await this._context.Entities.CallEntityAsync<AgentRunResponse>(
durableThread.SessionId,
nameof(AgentEntity.RunAgentAsync),
nameof(AgentEntity.Run),
request);
}
catch (EntityOperationFailedException e) when (e.FailureDetails.ErrorType == "EntityTaskNotFound")
@@ -9,23 +9,67 @@ public sealed class DurableAgentsOptions
{
// Agent names are case-insensitive
private readonly Dictionary<string, Func<IServiceProvider, AIAgent>> _agentFactories = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, TimeSpan?> _agentTimeToLive = new(StringComparer.OrdinalIgnoreCase);
internal DurableAgentsOptions()
{
}
/// <summary>
/// Gets or sets the default time-to-live (TTL) for agent entities.
/// </summary>
/// <remarks>
/// If an agent entity is idle for this duration, it will be automatically deleted.
/// Defaults to 14 days. Set to <see langword="null"/> to disable TTL for agents without explicit TTL configuration.
/// </remarks>
public TimeSpan? DefaultTimeToLive { get; set; } = TimeSpan.FromDays(14);
/// <summary>
/// Gets or sets the minimum delay for scheduling TTL deletion signals. Defaults to 5 minutes.
/// </summary>
/// <remarks>
/// This property is primarily useful for testing (where shorter delays are needed) or for
/// shorter-lived agents in workflows that need more rapid cleanup. The maximum allowed value is 5 minutes.
/// Reducing the minimum deletion delay below 5 minutes can be useful for testing or for ensuring rapid cleanup of short-lived agent sessions.
/// However, this can also increase the load on the system and should be used with caution.
/// </remarks>
/// <exception cref="ArgumentOutOfRangeException">Thrown when the value exceeds 5 minutes.</exception>
public TimeSpan MinimumTimeToLiveSignalDelay
{
get;
set
{
const int MaximumDelayMinutes = 5;
if (value > TimeSpan.FromMinutes(MaximumDelayMinutes))
{
throw new ArgumentOutOfRangeException(
nameof(value),
value,
$"The minimum time-to-live signal delay cannot exceed {MaximumDelayMinutes} minutes.");
}
field = value;
}
} = TimeSpan.FromMinutes(5);
/// <summary>
/// Adds an AI agent factory to the options.
/// </summary>
/// <param name="name">The name of the agent.</param>
/// <param name="factory">The factory function to create the agent.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="name"/> or <paramref name="factory"/> is null.</exception>
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory)
public DurableAgentsOptions AddAIAgentFactory(string name, Func<IServiceProvider, AIAgent> factory, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(factory);
this._agentFactories.Add(name, factory);
if (timeToLive.HasValue)
{
this._agentTimeToLive[name] = timeToLive;
}
return this;
}
@@ -50,12 +94,13 @@ public sealed class DurableAgentsOptions
/// Adds an AI agent to the options.
/// </summary>
/// <param name="agent">The agent to add.</param>
/// <param name="timeToLive">Optional time-to-live for this agent's entities. If not specified, uses <see cref="DefaultTimeToLive"/>.</param>
/// <returns>The options instance.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown when <paramref name="agent.Name"/> is null or whitespace or when an agent with the same name has already been registered.
/// </exception>
public DurableAgentsOptions AddAIAgent(AIAgent agent)
public DurableAgentsOptions AddAIAgent(AIAgent agent, TimeSpan? timeToLive = null)
{
ArgumentNullException.ThrowIfNull(agent);
@@ -70,6 +115,11 @@ public sealed class DurableAgentsOptions
}
this._agentFactories.Add(agent.Name, sp => agent);
if (timeToLive.HasValue)
{
this._agentTimeToLive[agent.Name] = timeToLive;
}
return this;
}
@@ -81,4 +131,14 @@ public sealed class DurableAgentsOptions
{
return this._agentFactories.AsReadOnly();
}
/// <summary>
/// Gets the time-to-live for a specific agent, or the default TTL if not specified.
/// </summary>
/// <param name="agentName">The name of the agent.</param>
/// <returns>The time-to-live for the agent, or the default TTL if not specified.</returns>
internal TimeSpan? GetTimeToLive(string agentName)
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
}
@@ -46,4 +46,58 @@ internal static partial class Logs
Level = LogLevel.Information,
Message = "Found response for agent with session ID '{SessionId}' with correlation ID '{CorrelationId}'")]
public static partial void LogDonePollingForResponse(this ILogger logger, AgentSessionId sessionId, string correlationId);
[LoggerMessage(
EventId = 6,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL expiration time updated to {ExpirationTime:O}")]
public static partial void LogTTLExpirationTimeUpdated(
this ILogger logger,
AgentSessionId sessionId,
DateTime expirationTime);
[LoggerMessage(
EventId = 7,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion signal scheduled for {ScheduledTime:O}")]
public static partial void LogTTLDeletionScheduled(
this ILogger logger,
AgentSessionId sessionId,
DateTime scheduledTime);
[LoggerMessage(
EventId = 8,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion check running. Expiration time: {ExpirationTime:O}, Current time: {CurrentTime:O}")]
public static partial void LogTTLDeletionCheck(
this ILogger logger,
AgentSessionId sessionId,
DateTime? expirationTime,
DateTime currentTime);
[LoggerMessage(
EventId = 9,
Level = LogLevel.Information,
Message = "[{SessionId}] Entity expired and deleted due to TTL. Expiration time: {ExpirationTime:O}")]
public static partial void LogTTLEntityExpired(
this ILogger logger,
AgentSessionId sessionId,
DateTime expirationTime);
[LoggerMessage(
EventId = 10,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL deletion signal rescheduled for {ScheduledTime:O}")]
public static partial void LogTTLRescheduled(
this ILogger logger,
AgentSessionId sessionId,
DateTime scheduledTime);
[LoggerMessage(
EventId = 11,
Level = LogLevel.Information,
Message = "[{SessionId}] TTL expiration time cleared (TTL disabled)")]
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
}
@@ -85,6 +85,9 @@ public static class ServiceCollectionExtensions
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// Register the options so AgentEntity can access TTL configuration
services.AddSingleton(options);
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
{
@@ -17,6 +17,13 @@ internal sealed class DurableAgentStateData
[JsonPropertyName("conversationHistory")]
public IList<DurableAgentStateEntry> ConversationHistory { get; init; } = [];
/// <summary>
/// Gets or sets the expiration time (UTC) for this agent entity.
/// If the entity is idle beyond this time, it will be automatically deleted.
/// </summary>
[JsonPropertyName("expirationTimeUtc")]
public DateTime? ExpirationTimeUtc { get; set; }
/// <summary>
/// Gets any additional data found during deserialization that does not map to known properties.
/// </summary>
@@ -84,22 +84,18 @@ internal sealed class ConversationReferenceJsonConverter : JsonConverter<Convers
return;
}
// If only ID is present and no metadata, serialize as a simple string
if (value.Metadata is null || value.Metadata.Count == 0)
// Ideally if only ID is present and no metadata, we would serialize as a simple string.
// However, while a request's "conversation" property can be either a string or an object
// containing a string, a response's "conversation" property is always an object. Since
// here we don't know which scenario we're in, we always serialize as an object, which works
// in any scenario.
writer.WriteStartObject();
writer.WriteString("id", value.Id);
if (value.Metadata is not null)
{
writer.WriteStringValue(value.Id);
}
else
{
// Otherwise, serialize as an object
writer.WriteStartObject();
writer.WriteString("id", value.Id);
if (value.Metadata is not null)
{
writer.WritePropertyName("metadata");
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
}
writer.WriteEndObject();
writer.WritePropertyName("metadata");
JsonSerializer.Serialize(writer, value.Metadata, OpenAIHostingJsonContext.Default.DictionaryStringString);
}
writer.WriteEndObject();
}
}
@@ -73,22 +73,22 @@ public static class AIAgentWithOpenAIExtensions
}
/// <summary>
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="OpenAIResponse"/>.
/// Runs the AI agent with a collection of OpenAI response items and returns the response as a native OpenAI <see cref="ResponseResult"/>.
/// </summary>
/// <param name="agent">The AI agent to run.</param>
/// <param name="messages">The collection of OpenAI response items to send to the agent.</param>
/// <param name="thread">The conversation thread to continue with this invocation. If not provided, creates a new thread. The thread will be mutated with the provided messages and agent response.</param>
/// <param name="options">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A <see cref="Task{OpenAIResponse}"/> representing the asynchronous operation that returns a native OpenAI <see cref="OpenAIResponse"/> response.</returns>
/// <returns>A <see cref="Task{ResponseResult}"/> representing the asynchronous operation that returns a native OpenAI <see cref="ResponseResult"/> response.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="agent"/> or <paramref name="messages"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="OpenAIResponse"/>, typically when the underlying representation is not an OpenAI response.</exception>
/// <exception cref="ArgumentException">Thrown when the agent's response cannot be converted to an <see cref="ResponseResult"/>, typically when the underlying representation is not an OpenAI response.</exception>
/// <exception cref="NotSupportedException">Thrown when any message in <paramref name="messages"/> has a type that is not supported by the message conversion method.</exception>
/// <remarks>
/// This method converts the OpenAI response items to the Microsoft Extensions AI format using the appropriate conversion method,
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="OpenAIResponse"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
/// runs the agent with the converted message collection, and then extracts the native OpenAI <see cref="ResponseResult"/> from the response using <see cref="AgentRunResponseExtensions.AsOpenAIResponse"/>.
/// </remarks>
public static async Task<OpenAIResponse> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
public static async Task<ResponseResult> RunAsync(this AIAgent agent, IEnumerable<ResponseItem> messages, AgentThread? thread = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(agent);
Throw.IfNull(messages);
@@ -29,17 +29,17 @@ public static class AgentRunResponseExtensions
}
/// <summary>
/// Creates or extracts a native OpenAI <see cref="OpenAIResponse"/> object from an <see cref="AgentRunResponse"/>.
/// Creates or extracts a native OpenAI <see cref="ResponseResult"/> object from an <see cref="AgentRunResponse"/>.
/// </summary>
/// <param name="response">The agent response.</param>
/// <returns>The OpenAI <see cref="OpenAIResponse"/> object.</returns>
/// <returns>The OpenAI <see cref="ResponseResult"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="response"/> is <see langword="null"/>.</exception>
public static OpenAIResponse AsOpenAIResponse(this AgentRunResponse response)
public static ResponseResult AsOpenAIResponse(this AgentRunResponse response)
{
Throw.IfNull(response);
return
response.RawRepresentation as OpenAIResponse ??
response.AsChatResponse().AsOpenAIResponse();
response.RawRepresentation as ResponseResult ??
response.AsChatResponse().AsOpenAIResponseResult();
}
}
@@ -8,7 +8,7 @@ using Microsoft.Shared.Diagnostics;
namespace OpenAI.Responses;
/// <summary>
/// Provides extension methods for <see cref="OpenAIResponseClient"/>
/// Provides extension methods for <see cref="ResponsesClient"/>
/// to simplify the creation of AI agents that work with OpenAI services.
/// </summary>
/// <remarks>
@@ -20,9 +20,9 @@ namespace OpenAI.Responses;
public static class OpenAIResponseClientExtensions
{
/// <summary>
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
/// </summary>
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
/// <param name="client">The <see cref="ResponsesClient" /> to use for the agent.</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>
@@ -33,7 +33,7 @@ public static class OpenAIResponseClientExtensions
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is <see langword="null"/>.</exception>
public static ChatClientAgent CreateAIAgent(
this OpenAIResponseClient client,
this ResponsesClient client,
string? instructions = null,
string? name = null,
string? description = null,
@@ -61,9 +61,9 @@ public static class OpenAIResponseClientExtensions
}
/// <summary>
/// Creates an AI agent from an <see cref="OpenAIResponseClient"/> using the OpenAI Response API.
/// Creates an AI agent from an <see cref="ResponsesClient"/> using the OpenAI Response API.
/// </summary>
/// <param name="client">The <see cref="OpenAIResponseClient" /> to use for the agent.</param>
/// <param name="client">The <see cref="ResponsesClient" /> to use for 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="loggerFactory">Optional logger factory for enabling logging within the agent.</param>
@@ -71,7 +71,7 @@ public static class OpenAIResponseClientExtensions
/// <returns>An <see cref="ChatClientAgent"/> instance backed by the OpenAI Response service.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> or <paramref name="options"/> is <see langword="null"/>.</exception>
public static ChatClientAgent CreateAIAgent(
this OpenAIResponseClient client,
this ResponsesClient client,
ChatClientAgentOptions options,
Func<IChatClient, IChatClient>? clientFactory = null,
ILoggerFactory? loggerFactory = null,
@@ -111,7 +111,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
if (inputArguments is not null)
{
JsonNode jsonNode = ConvertDictionaryToJson(inputArguments);
ResponseCreationOptions responseCreationOptions = new();
CreateResponseOptions responseCreationOptions = new();
#pragma warning disable SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
responseCreationOptions.Patch.Set("$.structured_inputs"u8, BinaryData.FromString(jsonNode.ToJsonString()));
#pragma warning restore SCME0001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
@@ -206,7 +206,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
public override async Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default)
{
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
ResponseItem[] items = [responseItem.AsResponseResultItem()];
return items.AsChatMessages().Single();
}
@@ -223,7 +223,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
await foreach (AgentResponseItem responseItem in this.GetConversationClient().GetProjectConversationItemsAsync(conversationId, null, limit, order.ToString(), after, before, include: null, cancellationToken).ConfigureAwait(false))
{
ResponseItem[] items = [responseItem.AsOpenAIResponseItem()];
ResponseItem[] items = [responseItem.AsResponseResultItem()];
foreach (ChatMessage message in items.AsChatMessages())
{
yield return message;
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public sealed class DirectEdgeData : EdgeData
{
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null) : base(id)
internal DirectEdgeData(string sourceId, string sinkId, EdgeId id, PredicateT? condition = null, string? label = null) : base(id, label)
{
this.SourceId = sourceId;
this.SinkId = sinkId;
@@ -14,10 +14,16 @@ public abstract class EdgeData
/// </summary>
internal abstract EdgeConnection Connection { get; }
internal EdgeData(EdgeId id)
internal EdgeData(EdgeId id, string? label = null)
{
this.Id = id;
this.Label = label;
}
internal EdgeId Id { get; }
/// <summary>
/// An optional label for the edge, allowing for arbitrary metadata to be associated with it.
/// </summary>
public string? Label { get; }
}
@@ -10,7 +10,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
internal sealed class FanInEdgeData : EdgeData
{
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id) : base(id)
internal FanInEdgeData(List<string> sourceIds, string sinkId, EdgeId id, string? label) : base(id, label)
{
this.SourceIds = sourceIds;
this.SinkId = sinkId;
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
internal sealed class FanOutEdgeData : EdgeData
{
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null) : base(edgeId)
internal FanOutEdgeData(string sourceId, List<string> sinkIds, EdgeId edgeId, AssignerF? assigner = null, string? label = null) : base(edgeId, label)
{
this.SourceId = sourceId;
this.SinkIds = sinkIds;
@@ -99,10 +99,30 @@ public static class WorkflowVisualizer
}
// Emit normal edges
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
{
var edgeAttr = isConditional ? " [style=dashed, label=\"conditional\"]" : "";
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{edgeAttr};");
// Build edge attributes
var attributes = new List<string>();
// Add style for conditional edges
if (isConditional)
{
attributes.Add("style=dashed");
}
// Add label (custom label or default "conditional" for conditional edges)
if (label != null)
{
attributes.Add($"label=\"{EscapeDotLabel(label)}\"");
}
else if (isConditional)
{
attributes.Add("label=\"conditional\"");
}
// Combine attributes
var attrString = attributes.Count > 0 ? $" [{string.Join(", ", attributes)}]" : "";
lines.Add($"{indent}\"{MapId(src)}\" -> \"{MapId(target)}\"{attrString};");
}
}
@@ -133,12 +153,7 @@ public static class WorkflowVisualizer
private static void EmitWorkflowMermaid(Workflow workflow, List<string> lines, string indent, string? ns = null)
{
string sanitize(string input)
{
return input;
}
string MapId(string id) => ns != null ? $"{sanitize(ns)}/{sanitize(id)}" : id;
string MapId(string id) => ns != null ? $"{ns}/{id}" : id;
// Add start node
var startExecutorId = workflow.StartExecutorId;
@@ -175,14 +190,23 @@ public static class WorkflowVisualizer
}
// Emit normal edges
foreach (var (src, target, isConditional) in ComputeNormalEdges(workflow))
foreach (var (src, target, isConditional, label) in ComputeNormalEdges(workflow))
{
if (isConditional)
{
lines.Add($"{indent}{MapId(src)} -. conditional .--> {MapId(target)};");
string effectiveLabel = label != null ? EscapeMermaidLabel(label) : "conditional";
// Conditional edge, with user label or default
lines.Add($"{indent}{MapId(src)} -. {effectiveLabel} .--> {MapId(target)};");
}
else if (label != null)
{
// Regular edge with label
lines.Add($"{indent}{MapId(src)} -->|{EscapeMermaidLabel(label)}| {MapId(target)};");
}
else
{
// Regular edge without label
lines.Add($"{indent}{MapId(src)} --> {MapId(target)};");
}
}
@@ -214,9 +238,9 @@ public static class WorkflowVisualizer
return result;
}
private static List<(string Source, string Target, bool IsConditional)> ComputeNormalEdges(Workflow workflow)
private static List<(string Source, string Target, bool IsConditional, string? Label)> ComputeNormalEdges(Workflow workflow)
{
var edges = new List<(string, string, bool)>();
var edges = new List<(string, string, bool, string?)>();
foreach (var edgeGroup in workflow.Edges.Values.SelectMany(x => x))
{
if (edgeGroup.Kind == EdgeKind.FanIn)
@@ -229,14 +253,15 @@ public static class WorkflowVisualizer
case EdgeKind.Direct when edgeGroup.DirectEdgeData != null:
var directData = edgeGroup.DirectEdgeData;
var isConditional = directData.Condition != null;
edges.Add((directData.SourceId, directData.SinkId, isConditional));
var label = directData.Label;
edges.Add((directData.SourceId, directData.SinkId, isConditional, label));
break;
case EdgeKind.FanOut when edgeGroup.FanOutEdgeData != null:
var fanOutData = edgeGroup.FanOutEdgeData;
foreach (var sinkId in fanOutData.SinkIds)
{
edges.Add((fanOutData.SourceId, sinkId, false));
edges.Add((fanOutData.SourceId, sinkId, false, fanOutData.Label));
}
break;
}
@@ -276,5 +301,24 @@ public static class WorkflowVisualizer
return false;
}
// Helper method to escape special characters in DOT labels
private static string EscapeDotLabel(string label)
{
return label.Replace("\"", "\\\"").Replace("\n", "\\n");
}
// Helper method to escape special characters in Mermaid labels
private static string EscapeMermaidLabel(string label)
{
return label
.Replace("&", "&amp;") // Must be first to avoid double-escaping
.Replace("|", "&#124;") // Pipe breaks Mermaid delimiter syntax
.Replace("\"", "&quot;") // Quote character
.Replace("<", "&lt;") // Less than
.Replace(">", "&gt;") // Greater than
.Replace("\n", "<br/>") // Newline to HTML break
.Replace("\r", ""); // Remove carriage return
}
#endregion
}
@@ -168,6 +168,18 @@ public class WorkflowBuilder
return edges;
}
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target)
=> this.AddEdge<object>(source, target, null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
@@ -182,6 +194,20 @@ public class WorkflowBuilder
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, string? label = null, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, label, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
{
if (condition is null)
@@ -222,6 +248,20 @@ public class WorkflowBuilder
private EdgeId TakeEdgeId() => new(Interlocked.Increment(ref this._edgeCount));
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null)
=> this.AddEdge(source, target, condition, label: null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
@@ -236,6 +276,23 @@ public class WorkflowBuilder
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
=> this.AddEdge(source, target, condition, label: null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, string? label = null, bool idempotent = false)
{
// Add an edge from source to target with an optional condition.
// This is a low-level builder method that does not enforce any specific executor type.
@@ -256,7 +313,7 @@ public class WorkflowBuilder
"You cannot add another edge without a condition for the same source and target.");
}
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition));
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label);
this.EnsureEdgesFor(source.Id).Add(new(directEdge));
@@ -275,6 +332,19 @@ public class WorkflowBuilder
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, targets, null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <param name="label">A label for the edge. Will be used in visualization.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, string label)
=> this.AddFanOutEdge<object>(source, targets, null, label);
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
{
if (targetAssigner is null)
@@ -305,6 +375,21 @@ public class WorkflowBuilder
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
=> this.AddFanOutEdge(source, targets, targetSelector, label: null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null, string? label = null)
{
Throw.IfNull(source);
Throw.IfNull(targets);
@@ -321,7 +406,8 @@ public class WorkflowBuilder
this.Track(source).Id,
sinkIds,
this.TakeEdgeId(),
CreateTargetAssignerFunc(targetSelector));
CreateTargetAssignerFunc(targetSelector),
label);
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
@@ -339,6 +425,20 @@ public class WorkflowBuilder
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
=> this.AddFanInEdge(sources, target, label: null);
/// <summary>
/// Adds a fan-in edge to the workflow, connecting multiple source executors to a single target executor with an
/// optional trigger condition.
/// </summary>
/// <remarks>This method establishes a fan-in relationship, allowing the target executor to be activated
/// based on the completion or state of multiple sources. The trigger parameter can be used to customize activation
/// behavior.</remarks>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -354,7 +454,8 @@ public class WorkflowBuilder
FanInEdgeData edgeData = new(
sourceIds,
this.Track(target).Id,
this.TakeEdgeId());
this.TakeEdgeId(),
label);
foreach (string sourceId in edgeData.SourceIds)
{
@@ -89,7 +89,7 @@ public class AIProjectClientFixture : IChatClientAgentFixture
List<ChatMessage> messages = [];
await foreach (AgentResponseItem item in this._client.GetProjectOpenAIClient().GetProjectConversationsClient().GetProjectConversationItemsAsync(conversationId, order: "asc"))
{
var openAIItem = item.AsOpenAIResponseItem();
var openAIItem = item.AsResponseResultItem();
if (openAIItem is MessageResponseItem messageItem)
{
messages.Add(new ChatMessage
@@ -10,7 +10,6 @@ using Azure.Core;
using Azure.Identity;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.AI;
using Xunit;
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
@@ -59,6 +58,9 @@ public sealed class CosmosChatMessageStoreTests : IAsyncLifetime, IDisposable
public async Task InitializeAsync()
{
// Fail fast if emulator is not available
this.SkipIfEmulatorNotAvailable();
// Check environment variable to determine if we should preserve containers
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
@@ -7,7 +7,6 @@ using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Azure.Cosmos;
using Xunit;
namespace Microsoft.Agents.AI.CosmosNoSql.UnitTests;
@@ -58,6 +57,9 @@ public class CosmosCheckpointStoreTests : IAsyncLifetime, IDisposable
public async Task InitializeAsync()
{
// Fail fast if emulator is not available
this.SkipIfEmulatorNotAvailable();
// Check environment variable to determine if we should preserve containers
// Set COSMOS_PRESERVE_CONTAINERS=true to keep containers and data for inspection
this._preserveContainer = string.Equals(Environment.GetEnvironmentVariable("COSMOS_PRESERVE_CONTAINERS"), "true", StringComparison.OrdinalIgnoreCase);
@@ -81,6 +81,64 @@ public sealed class AgentEntityTests(ITestOutputHelper outputHelper) : IDisposab
Assert.Null(request.OrchestrationId);
}
[Theory]
[InlineData("run")]
[InlineData("Run")]
[InlineData("RunAgentAsync")]
public async Task RunAgentMethodNamesAllWorkAsync(string runAgentMethodName)
{
// Setup
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TestAgent",
instructions: "You are a helpful assistant that always responds with a friendly greeting."
);
using TestHelper testHelper = TestHelper.Start([simpleAgent], this._outputHelper);
// A proxy agent is needed to call the hosted test agent
AIAgent simpleAgentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = simpleAgentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
EntityInstanceId expectedEntityId = new($"dafx-{simpleAgent.Name}", sessionId.Key);
EntityMetadata? entity = await client.Entities.GetEntityAsync(expectedEntityId, false, this.TestTimeoutToken);
Assert.Null(entity);
// Act: send a prompt to the agent
await client.Entities.SignalEntityAsync(
expectedEntityId,
runAgentMethodName,
new RunRequest("Hello!"),
cancellation: this.TestTimeoutToken);
while (!this.TestTimeoutToken.IsCancellationRequested)
{
await Task.Delay(500, this.TestTimeoutToken);
// Assert: verify the agent state was stored with the correct entity name prefix
entity = await client.Entities.GetEntityAsync(expectedEntityId, true, this.TestTimeoutToken);
if (entity is not null)
{
break;
}
}
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DurableAgentStateRequest request = Assert.Single(state.Data.ConversationHistory.OfType<DurableAgentStateRequest>());
Assert.Null(request.OrchestrationId);
}
[Fact]
public async Task OrchestrationIdSetDuringOrchestrationAsync()
{
@@ -0,0 +1,197 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Client.Entities;
using Microsoft.Extensions.Configuration;
using OpenAI.Chat;
using Xunit.Abstractions;
namespace Microsoft.Agents.AI.DurableTask.IntegrationTests;
/// <summary>
/// Tests for Time-To-Live (TTL) functionality of durable agent entities.
/// </summary>
[Collection("Sequential")]
[Trait("Category", "Integration")]
public sealed class TimeToLiveTests(ITestOutputHelper outputHelper) : IDisposable
{
private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached
? TimeSpan.FromMinutes(5)
: TimeSpan.FromSeconds(30);
private static readonly IConfiguration s_configuration =
new ConfigurationBuilder()
.AddUserSecrets(Assembly.GetExecutingAssembly())
.AddEnvironmentVariables()
.Build();
private readonly ITestOutputHelper _outputHelper = outputHelper;
private readonly CancellationTokenSource _cts = new(delay: s_defaultTimeout);
private CancellationToken TestTimeoutToken => this._cts.Token;
public void Dispose() => this._cts.Dispose();
[Fact]
public async Task EntityExpiresAfterTTLAsync()
{
// Arrange: Create agent with short TTL (10 seconds)
TimeSpan ttl = TimeSpan.FromSeconds(10);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TTLTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = agentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send a message to the agent
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify entity exists and get expiration time
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state.Data.ExpirationTimeUtc);
DateTime expirationTime = state.Data.ExpirationTimeUtc.Value;
Assert.True(expirationTime > DateTime.UtcNow);
// Calculate how long to wait: expiration time + buffer for signal processing
TimeSpan waitTime = expirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(1);
if (waitTime > TimeSpan.Zero)
{
await Task.Delay(waitTime, this.TestTimeoutToken);
}
// Poll the entity state until it's deleted (with timeout)
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Verify entity state is deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
[Fact]
public async Task EntityTTLResetsOnInteractionAsync()
{
// Arrange: Create agent with short TTL
TimeSpan ttl = TimeSpan.FromSeconds(6);
AIAgent simpleAgent = TestHelper.GetAzureOpenAIChatClient(s_configuration).CreateAIAgent(
name: "TTLResetTestAgent",
instructions: "You are a helpful assistant."
);
using TestHelper testHelper = TestHelper.Start(
this._outputHelper,
options =>
{
options.DefaultTimeToLive = ttl;
options.MinimumTimeToLiveSignalDelay = TimeSpan.FromSeconds(1);
options.AddAIAgent(simpleAgent);
});
AIAgent agentProxy = simpleAgent.AsDurableAgentProxy(testHelper.Services);
AgentThread thread = agentProxy.GetNewThread();
DurableTaskClient client = testHelper.GetClient();
AgentSessionId sessionId = thread.GetService<AgentSessionId>();
// Act: Send first message
await agentProxy.RunAsync(
message: "Hello!",
thread,
cancellationToken: this.TestTimeoutToken);
EntityMetadata? entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
DurableAgentState state = entity.State.ReadAs<DurableAgentState>();
DateTime firstExpirationTime = state.Data.ExpirationTimeUtc!.Value;
// Wait partway through TTL
await Task.Delay(TimeSpan.FromSeconds(3), this.TestTimeoutToken);
// Send second message (should reset TTL)
await agentProxy.RunAsync(
message: "Hello again!",
thread,
cancellationToken: this.TestTimeoutToken);
// Verify expiration time was updated
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
DateTime secondExpirationTime = state.Data.ExpirationTimeUtc!.Value;
Assert.True(secondExpirationTime > firstExpirationTime);
// Calculate when the original expiration time would have been
DateTime originalExpirationTime = firstExpirationTime;
TimeSpan waitUntilOriginalExpiration = originalExpirationTime - DateTime.UtcNow + TimeSpan.FromSeconds(2);
if (waitUntilOriginalExpiration > TimeSpan.Zero)
{
await Task.Delay(waitUntilOriginalExpiration, this.TestTimeoutToken);
}
// Assert: Entity should still exist because TTL was reset
// The new expiration time should be in the future
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
Assert.NotNull(entity);
Assert.True(entity.IncludesState);
state = entity.State.ReadAs<DurableAgentState>();
Assert.NotNull(state);
Assert.NotNull(state.Data.ExpirationTimeUtc);
Assert.True(
state.Data.ExpirationTimeUtc > DateTime.UtcNow,
"Entity should still be valid because TTL was reset");
// Wait for the entity to be deleted
DateTime pollTimeout = DateTime.UtcNow.AddSeconds(10);
bool entityDeleted = false;
while (DateTime.UtcNow < pollTimeout && !entityDeleted)
{
entity = await client.Entities.GetEntityAsync(sessionId, true, this.TestTimeoutToken);
entityDeleted = entity is null;
if (!entityDeleted)
{
await Task.Delay(TimeSpan.FromSeconds(1), this.TestTimeoutToken);
}
}
// Assert: Entity should have been deleted
Assert.True(entityDeleted, "Entity should have been deleted after TTL expiration");
}
}
@@ -49,7 +49,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "One Two Three";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Count to 3");
@@ -90,10 +90,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello! How can I help you today?";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Hello");
ResponseResult response = await responseClient.CreateResponseAsync("Hello");
// Assert
Assert.NotNull(response);
@@ -117,7 +117,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "This is a test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -162,12 +162,12 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
(Agent1Name, Agent1Instructions, Agent1Response),
(Agent2Name, Agent2Instructions, Agent2Response));
OpenAIResponseClient responseClient1 = this.CreateResponseClient(Agent1Name);
OpenAIResponseClient responseClient2 = this.CreateResponseClient(Agent2Name);
ResponsesClient responseClient1 = this.CreateResponseClient(Agent1Name);
ResponsesClient responseClient2 = this.CreateResponseClient(Agent2Name);
// Act
OpenAIResponse response1 = await responseClient1.CreateResponseAsync("Hello");
OpenAIResponse response2 = await responseClient2.CreateResponseAsync("Hello");
ResponseResult response1 = await responseClient1.CreateResponseAsync("Hello");
ResponseResult response2 = await responseClient2.CreateResponseAsync("Hello");
// Assert
string content1 = response1.GetOutputText();
@@ -190,10 +190,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "This is the response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act - Non-streaming
OpenAIResponse nonStreamingResponse = await responseClient.CreateResponseAsync("Test");
ResponseResult nonStreamingResponse = await responseClient.CreateResponseAsync("Test");
// Act - Streaming
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -224,10 +224,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Complete";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.Equal(ResponseStatus.Completed, response.Status);
@@ -247,7 +247,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test response with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -286,7 +286,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -316,10 +316,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response with metadata";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.NotNull(response.Id);
@@ -340,7 +340,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
string expectedResponse = string.Join(" ", Enumerable.Range(1, 100).Select(i => $"Word{i}"));
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, expectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Generate long text");
@@ -371,7 +371,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test output index";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -407,7 +407,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -437,7 +437,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello! How are you? I'm fine. 100% great!";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -467,10 +467,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Symbols: @#$%^&*() Quotes: \"Hello\" 'World' Unicode: 你好 🌍";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
string content = response.GetOutputText();
@@ -489,7 +489,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Testing item IDs";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -525,12 +525,12 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act & Assert - Make 5 sequential requests
for (int i = 0; i < 5; i++)
{
OpenAIResponse response = await responseClient.CreateResponseAsync($"Request {i}");
ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}");
Assert.NotNull(response);
Assert.Equal(ResponseStatus.Completed, response.Status);
Assert.Equal(ExpectedResponse, response.GetOutputText());
@@ -549,7 +549,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Streaming response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act & Assert - Make 3 sequential streaming requests
for (int i = 0; i < 3; i++)
@@ -581,13 +581,13 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
List<string> responseIds = [];
for (int i = 0; i < 10; i++)
{
OpenAIResponse response = await responseClient.CreateResponseAsync($"Request {i}");
ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}");
responseIds.Add(response.Id);
}
@@ -608,7 +608,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test sequence numbers with multiple words";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -641,10 +641,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test model info";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
Assert.NotNull(response.Model);
@@ -663,7 +663,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Hello, world! How are you today? I'm doing well.";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -693,10 +693,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "OK";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Hi");
ResponseResult response = await responseClient.CreateResponseAsync("Hi");
// Assert
Assert.NotNull(response);
@@ -716,7 +716,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Test content indices";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -748,10 +748,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Line 1\nLine 2\nLine 3";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Test");
ResponseResult response = await responseClient.CreateResponseAsync("Test");
// Assert
string content = response.GetOutputText();
@@ -771,7 +771,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "First line\nSecond line\nThird line";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -807,10 +807,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.ImageContentMockChatClient(ImageUrl));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Show me an image");
ResponseResult response = await responseClient.CreateResponseAsync("Show me an image");
// Assert
Assert.NotNull(response);
@@ -834,7 +834,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.ImageContentMockChatClient(ImageUrl));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Show me an image");
@@ -868,10 +868,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.AudioContentMockChatClient(AudioData, Transcript));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Generate audio");
ResponseResult response = await responseClient.CreateResponseAsync("Generate audio");
// Assert
Assert.NotNull(response);
@@ -896,7 +896,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.AudioContentMockChatClient(AudioData, Transcript));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Generate audio");
@@ -930,10 +930,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("What's the weather?");
ResponseResult response = await responseClient.CreateResponseAsync("What's the weather?");
// Assert
Assert.NotNull(response);
@@ -957,7 +957,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.FunctionCallMockChatClient(FunctionName, Arguments));
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Calculate 2+2");
@@ -988,10 +988,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.MixedContentMockChatClient());
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
OpenAIResponse response = await responseClient.CreateResponseAsync("Show me various content");
ResponseResult response = await responseClient.CreateResponseAsync("Show me various content");
// Assert
Assert.NotNull(response);
@@ -1014,7 +1014,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
instructions: Instructions,
chatClient: new TestHelpers.MixedContentMockChatClient());
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Show me various content");
@@ -1047,7 +1047,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Complete text response";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -1075,7 +1075,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
const string ExpectedResponse = "Response with content parts";
this._httpClient = await this.CreateTestServerAsync(AgentName, Instructions, ExpectedResponse);
OpenAIResponseClient responseClient = this.CreateResponseClient(AgentName);
ResponsesClient responseClient = this.CreateResponseClient(AgentName);
// Act
AsyncCollectionResult<StreamingResponseUpdate> streamingResult = responseClient.CreateResponseStreamingAsync("Test");
@@ -1122,7 +1122,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
string conversationId = convDoc.RootElement.GetProperty("id").GetString()!;
// Act - Send request with conversation ID using raw HTTP
// (OpenAI SDK doesn't expose ConversationId directly on ResponseCreationOptions)
// (OpenAI SDK doesn't expose ConversationId directly on CreateResponseOptions)
var requestBody = new
{
input = "Test",
@@ -1201,9 +1201,9 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable
Assert.Null(mockChatClient.LastChatOptions.ConversationId);
}
private OpenAIResponseClient CreateResponseClient(string agentName)
private ResponsesClient CreateResponseClient(string agentName)
{
return new OpenAIResponseClient(
return new ResponsesClient(
model: "test-model",
credential: new ApiKeyCredential("test-api-key"),
options: new OpenAIClientOptions
@@ -55,9 +55,9 @@ public sealed class OpenAIResponseClientExtensionsTests
}
/// <summary>
/// Creates a test OpenAIResponseClient implementation for testing.
/// Creates a test ResponsesClient implementation for testing.
/// </summary>
private sealed class TestOpenAIResponseClient : OpenAIResponseClient
private sealed class TestOpenAIResponseClient : ResponsesClient
{
public TestOpenAIResponseClient()
{
@@ -147,7 +147,7 @@ public sealed class OpenAIResponseClientExtensionsTests
{
// Act & Assert
var exception = Assert.Throws<ArgumentNullException>(() =>
((OpenAIResponseClient)null!).CreateAIAgent());
((ResponsesClient)null!).CreateAIAgent());
Assert.Equal("client", exception.ParamName);
}
@@ -24,7 +24,7 @@ public sealed class MediaInputTest(ITestOutputHelper output) : IntegrationTest(o
private const string ImageReference = "https://sample-files.com/downloads/images/jpg/web_optimized_1200x800_97kb.jpg";
[Theory]
[InlineData(ImageReference, "image/jpeg")]
[InlineData(ImageReference, "image/jpeg", Skip = "Failing consistently in the agent service api")]
[InlineData(PdfReference, "application/pdf", Skip = "Not currently supported by agent service api")]
public async Task ValidateFileUrlAsync(string fileSource, string mediaType)
{
@@ -21,7 +21,7 @@ public class EdgeMapSmokeTests
Dictionary<string, HashSet<Edge>> workflowEdges = [];
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
Edge fanInEdge = new(edgeData);
workflowEdges["executor1"] = [fanInEdge];
@@ -155,7 +155,7 @@ public class EdgeRunnerTests
runContext.Executors["executor2"] = new ForwardMessageExecutor<string>("executor2");
runContext.Executors["executor3"] = new ForwardMessageExecutor<string>("executor3");
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0));
FanInEdgeData edgeData = new(["executor1", "executor2"], "executor3", new EdgeId(0), null);
FanInEdgeRunner runner = new(runContext, edgeData);
// Step 1: Send message from executor1, should not forward yet.
@@ -118,7 +118,7 @@ public class JsonSerializationTests
RunJsonRoundtrip(TestFanOutEdgeInfo_Assigner, predicate: TestFanOutEdgeInfo_Assigner.CreateValidator());
}
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId());
private static FanInEdgeData TestFanInEdgeData => new(["SourceExecutor1", "SourceExecutor2"], "TargetExecutor", TakeEdgeId(), null);
private static FanInEdgeInfo TestFanInEdgeInfo => new(TestFanInEdgeData);
[Fact]
@@ -137,17 +137,17 @@ public class RepresentationTests
RunEdgeInfoMatchTest(fanOutEdgeWithAssigner);
// FanIn Edges
Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId()));
Edge fanInEdge = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge);
Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId()));
Edge fanInEdge2 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge2);
Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId()));
Edge fanInEdge3 = new(new FanInEdgeData([Source(2), Source(3), Source(1)], Sink(1), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge3, expect: false); // Order matters (though for FanIn maybe it shouldn't?)
Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId()));
Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId()));
Edge fanInEdge4 = new(new FanInEdgeData([Source(1), Source(2), Source(4)], Sink(1), TakeEdgeId(), null));
Edge fanInEdge5 = new(new FanInEdgeData([Source(1), Source(2), Source(3)], Sink(2), TakeEdgeId(), null));
RunEdgeInfoMatchTest(fanInEdge, fanInEdge4, expect: false); // Identity matters
RunEdgeInfoMatchTest(fanInEdge, fanInEdge5, expect: false);
@@ -394,4 +394,61 @@ public class WorkflowVisualizerTests
// Check fan-in (should have intermediate node)
mermaidContent.Should().Contain("((fan-in))");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Pipe()
{
// Test that pipe characters in labels are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "High | Low Priority")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape pipe character
mermaidContent.Should().Contain("start -->|High &#124; Low Priority| end");
// Should not contain unescaped pipe that would break syntax
mermaidContent.Should().NotContain("-->|High | Low");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Special_Chars()
{
// Test that special characters are properly escaped
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Score >= 90 & < 100")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should escape special characters
mermaidContent.Should().Contain("&amp;");
mermaidContent.Should().Contain("&gt;");
mermaidContent.Should().Contain("&lt;");
}
[Fact]
public void Test_WorkflowViz_Mermaid_Edge_Label_With_Newline()
{
// Test that newlines are converted to <br/>
var start = new MockExecutor("start");
var end = new MockExecutor("end");
var workflow = new WorkflowBuilder("start")
.AddEdge(start, end, label: "Line 1\nLine 2")
.Build();
var mermaidContent = workflow.ToMermaidString();
// Should convert newline to <br/>
mermaidContent.Should().Contain("Line 1<br/>Line 2");
// Should not contain literal newline in the label (but the overall output has newlines between statements)
mermaidContent.Should().NotContain("Line 1\nLine 2");
}
}
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -16,7 +16,7 @@ public class OpenAIResponseStoreTrueChatClientAgentRunStreamingTests() : ChatCli
public class OpenAIResponseStoreFalseChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueChatClientAgentRunTests() : ChatClientAgentRunTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -16,7 +16,7 @@ public class OpenAIResponseStoreTrueChatClientAgentRunTests() : ChatClientAgentR
public class OpenAIResponseStoreFalseChatClientAgentRunTests() : ChatClientAgentRunTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithInstructionsAndNoMessageReturnsExpectedResultAsync() =>
@@ -12,13 +12,13 @@ using OpenAI;
using OpenAI.Responses;
using Shared.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
{
private static readonly OpenAIConfiguration s_config = TestConfiguration.LoadSection<OpenAIConfiguration>();
private OpenAIResponseClient _openAIResponseClient = null!;
private ResponsesClient _openAIResponseClient = null!;
private ChatClientAgent _agent = null!;
public AIAgent Agent => this._agent;
@@ -77,7 +77,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
{
Instructions = instructions,
Tools = aiTools,
RawRepresentationFactory = new Func<IChatClient, object>(_ => new ResponseCreationOptions() { StoredOutputEnabled = store })
RawRepresentationFactory = new Func<IChatClient, object>(_ => new CreateResponseOptions() { StoredOutputEnabled = store })
},
});
@@ -92,7 +92,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture
public async Task InitializeAsync()
{
this._openAIResponseClient = new OpenAIClient(s_config.ApiKey)
.GetOpenAIResponseClient(s_config.ChatModelId);
.GetResponsesClient(s_config.ChatModelId);
this._agent = await this.CreateChatClientAgentAsync();
}
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueRunStreamingTests() : RunStreamingTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
Task.CompletedTask;
@@ -15,7 +15,7 @@ public class OpenAIResponseStoreTrueRunStreamingTests() : RunStreamingTests<Open
public class OpenAIResponseStoreFalseRunStreamingTests() : RunStreamingTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
@@ -3,11 +3,11 @@
using System.Threading.Tasks;
using AgentConformance.IntegrationTests;
namespace OpenAIResponse.IntegrationTests;
namespace ResponseResult.IntegrationTests;
public class OpenAIResponseStoreTrueRunTests() : RunTests<OpenAIResponseFixture>(() => new(store: true))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
Task.CompletedTask;
@@ -15,7 +15,7 @@ public class OpenAIResponseStoreTrueRunTests() : RunTests<OpenAIResponseFixture>
public class OpenAIResponseStoreFalseRunTests() : RunTests<OpenAIResponseFixture>(() => new(store: false))
{
private const string SkipReason = "OpenAIResponse does not support empty messages";
private const string SkipReason = "ResponseResult does not support empty messages";
[Fact(Skip = SkipReason)]
public override Task RunWithNoMessageDoesNotFailAsync() =>
+2 -3
View File
@@ -33,7 +33,6 @@ ANTHROPIC_MODEL=""
OLLAMA_ENDPOINT=""
OLLAMA_MODEL=""
# Observability
ENABLE_OTEL=true
ENABLE_INSTRUMENTATION=true
ENABLE_SENSITIVE_DATA=true
OTLP_ENDPOINT="http://localhost:4317/"
# APPLICATIONINSIGHTS_CONNECTION_STRING="..."
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317/"
+28 -1
View File
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- **agent-framework-azurefunctions**: Durable Agents: platforms should use consistent entity method names (#2234)
## [1.0.0b251216] - 2025-12-16
### Added
- **agent-framework-ollama**: Ollama connector for Agent Framework (#1104)
- **agent-framework-core**: Added custom args and thread object to `ai_function` kwargs (#2769)
- **agent-framework-core**: Enable checkpointing for `WorkflowAgent` (#2774)
### Changed
- **agent-framework-core**: [BREAKING] Observability updates (#2782)
- **agent-framework-core**: Use agent description in `HandoffBuilder` auto-generated tools (#2714)
- **agent-framework-core**: Remove warnings from workflow builder when not using factories (#2808)
### Fixed
- **agent-framework-core**: Fix `WorkflowAgent` to include thread conversation history (#2774)
- **agent-framework-core**: Fix context duplication in handoff workflows when restoring from checkpoint (#2867)
- **agent-framework-core**: Fix middleware terminate flag to exit function calling loop immediately (#2868)
- **agent-framework-core**: Fix `WorkflowAgent` to emit `yield_output` as agent response (#2866)
- **agent-framework-core**: Filter framework kwargs from MCP tool invocations (#2870)
## [1.0.0b251211] - 2025-12-11
### Added
@@ -366,7 +392,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251216...HEAD
[1.0.0b251216]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251211...python-1.0.0b251216
[1.0.0b251211]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251209...python-1.0.0b251211
[1.0.0b251209]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251204...python-1.0.0b251209
[1.0.0b251204]: https://github.com/microsoft/agent-framework/compare/python-1.0.0b251120...python-1.0.0b251204
@@ -5,7 +5,7 @@ import json
import re
import uuid
from collections.abc import AsyncIterable, Sequence
from typing import Any, cast
from typing import Any, Final, cast
import httpx
from a2a.client import Client, ClientConfig, ClientFactory, minimal_agent_card
@@ -38,6 +38,7 @@ from agent_framework import (
UriContent,
prepend_agent_framework_to_user_agent,
)
from agent_framework.observability import use_agent_instrumentation
__all__ = ["A2AAgent"]
@@ -58,6 +59,7 @@ def _get_uri_data(uri: str) -> str:
return match.group("base64_data")
@use_agent_instrumentation
class A2AAgent(BaseAgent):
"""Agent2Agent (A2A) protocol implementation.
@@ -69,6 +71,8 @@ class A2AAgent(BaseAgent):
Can be initialized with a URL, AgentCard, or existing A2A Client instance.
"""
AGENT_PROVIDER_NAME: Final[str] = "A2A"
def __init__(
self,
*,
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ from agent_framework import (
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_observability
from agent_framework.observability import use_instrumentation
from ._event_converters import AGUIEventConverter
from ._http_service import AGUIHttpService
@@ -89,7 +89,7 @@ def _apply_server_function_call_unwrap(chat_client: TBaseChatClient) -> TBaseCha
@_apply_server_function_call_unwrap
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AGUIChatClient(BaseChatClient):
"""Chat client for communicating with AG-UI compliant servers.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b251211"
version = "1.0.0b251216"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -35,7 +35,7 @@ from agent_framework import (
)
from agent_framework._pydantic import AFBaseSettings
from agent_framework.exceptions import ServiceInitializationError
from agent_framework.observability import use_observability
from agent_framework.observability import use_instrumentation
from anthropic import AsyncAnthropic
from anthropic.types.beta import (
BetaContentBlock,
@@ -110,7 +110,7 @@ TAnthropicClient = TypeVar("TAnthropicClient", bound="AnthropicClient")
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AnthropicClient(BaseChatClient):
"""Anthropic Chat client."""
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -43,7 +43,7 @@ from agent_framework import (
use_function_invocation,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceResponseException
from agent_framework.observability import use_observability
from agent_framework.observability import use_instrumentation
from azure.ai.agents.aio import AgentsClient
from azure.ai.agents.models import (
Agent,
@@ -107,7 +107,7 @@ TAzureAIAgentClient = TypeVar("TAzureAIAgentClient", bound="AzureAIAgentClient")
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AzureAIAgentClient(BaseChatClient):
"""Azure AI Agent Chat client."""
@@ -15,7 +15,7 @@ from agent_framework import (
use_function_invocation,
)
from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError
from agent_framework.observability import use_observability
from agent_framework.observability import use_instrumentation
from agent_framework.openai._responses_client import OpenAIBaseResponsesClient
from azure.ai.projects.aio import AIProjectClient
from azure.ai.projects.models import (
@@ -49,7 +49,7 @@ TAzureAIClient = TypeVar("TAzureAIClient", bound="AzureAIClient")
@use_function_invocation
@use_observability
@use_instrumentation
@use_chat_middleware
class AzureAIClient(OpenAIBaseResponsesClient):
"""Azure AI Agent client."""
@@ -164,27 +164,94 @@ class AzureAIClient(OpenAIBaseResponsesClient):
# Track whether we should close client connection
self._should_close_client = should_close_client
async def setup_azure_ai_observability(self, enable_sensitive_data: bool | None = None) -> None:
"""Use this method to setup tracing in your Azure AI Project.
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
**kwargs: Any,
) -> None:
"""Setup observability with Azure Monitor (Azure AI Foundry integration).
This will take the connection string from the project project_client.
It will override any connection string that is set in the environment variables.
It will disable any OTLP endpoint that might have been set.
This method configures Azure Monitor for telemetry collection using the
connection string from the Azure AI project client.
Args:
enable_sensitive_data: Enable sensitive data logging (prompts, responses).
Should only be enabled in development/test environments. Default is False.
**kwargs: Additional arguments passed to configure_azure_monitor().
Common options include:
- enable_live_metrics (bool): Enable Azure Monitor Live Metrics
- credential (TokenCredential): Azure credential for Entra ID auth
- resource (Resource): Custom OpenTelemetry resource
See https://learn.microsoft.com/python/api/azure-monitor-opentelemetry/azure.monitor.opentelemetry.configure_azure_monitor
for full list of options.
Raises:
ImportError: If azure-monitor-opentelemetry-exporter is not installed.
Examples:
.. code-block:: python
from agent_framework.azure import AzureAIClient
from azure.ai.projects.aio import AIProjectClient
from azure.identity.aio import DefaultAzureCredential
async with (
DefaultAzureCredential() as credential,
AIProjectClient(
endpoint="https://your-project.api.azureml.ms", credential=credential
) as project_client,
AzureAIClient(project_client=project_client) as client,
):
# Setup observability with defaults
await client.configure_azure_monitor()
# With live metrics enabled
await client.configure_azure_monitor(enable_live_metrics=True)
# With sensitive data logging (dev/test only)
await client.configure_azure_monitor(enable_sensitive_data=True)
Note:
This method retrieves the Application Insights connection string from the
Azure AI project client automatically. You must have Application Insights
configured in your Azure AI project for this to work.
"""
# Get connection string from project client
try:
conn_string = await self.project_client.telemetry.get_application_insights_connection_string()
except ResourceNotFoundError:
logger.warning(
"No Application Insights connection string found for the Azure AI Project, "
"please call setup_observability() manually."
"No Application Insights connection string found for the Azure AI Project. "
"Please ensure Application Insights is configured in your Azure AI project, "
"or call configure_otel_providers() manually with custom exporters."
)
return
from agent_framework.observability import setup_observability
setup_observability(
applicationinsights_connection_string=conn_string, enable_sensitive_data=enable_sensitive_data
# Import Azure Monitor with proper error handling
try:
from azure.monitor.opentelemetry import configure_azure_monitor
except ImportError as exc:
raise ImportError(
"azure-monitor-opentelemetry is required for Azure Monitor integration. "
"Install it with: pip install azure-monitor-opentelemetry"
) from exc
from agent_framework.observability import create_metric_views, create_resource, enable_instrumentation
# Create resource if not provided in kwargs
if "resource" not in kwargs:
kwargs["resource"] = create_resource()
# Configure Azure Monitor with connection string and kwargs
configure_azure_monitor(
connection_string=conn_string,
views=create_metric_views(),
**kwargs,
)
# Complete setup with core observability
enable_instrumentation(enable_sensitive_data=enable_sensitive_data)
async def __aenter__(self) -> "Self":
"""Async context manager entry."""
return self
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -414,7 +414,7 @@ class AgentFunctionApp(DFAppBase):
request_response_format,
)
logger.debug("Signalling entity %s with request: %s", entity_instance_id, run_request)
await client.signal_entity(entity_instance_id, "run_agent", run_request)
await client.signal_entity(entity_instance_id, "run", run_request)
logger.debug(f"[HTTP Trigger] Signal sent to entity {session_id}")
@@ -495,7 +495,8 @@ class AgentFunctionApp(DFAppBase):
"""Durable entity that manages agent execution and conversation state.
Operations:
- run_agent: Execute the agent with a message
- run: Execute the agent with a message
- run_agent: (Deprecated) Execute the agent with a message
- reset: Clear conversation history
"""
entity_handler = create_agent_entity(agent, callback)
@@ -637,7 +638,7 @@ class AgentFunctionApp(DFAppBase):
logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview)
# Signal entity to run agent
await client.signal_entity(entity_instance_id, "run_agent", run_request)
await client.signal_entity(entity_instance_id, "run", run_request)
# Poll for response (similar to HTTP handler)
try:
@@ -46,7 +46,8 @@ class AgentEntity:
- Handles tool execution
Operations:
- run_agent: Execute the agent with a message
- run: Execute the agent with a message
- run_agent: (Deprecated) Execute the agent with a message
- reset: Clear conversation history
Attributes:
@@ -94,6 +95,22 @@ class AgentEntity:
self,
context: df.DurableEntityContext,
request: RunRequest | dict[str, Any] | str,
) -> AgentRunResponse:
"""(Deprecated) Execute the agent with a message directly in the entity.
Args:
context: Entity context
request: RunRequest object, dict, or string message (for backward compatibility)
Returns:
AgentRunResponse enriched with execution metadata.
"""
return await self.run(context, request)
async def run(
self,
context: df.DurableEntityContext,
request: RunRequest | dict[str, Any] | str,
) -> AgentRunResponse:
"""Execute the agent with a message directly in the entity.
@@ -124,7 +141,7 @@ class AgentEntity:
state_request = DurableAgentStateRequest.from_run_request(run_request)
self.state.data.conversation_history.append(state_request)
logger.debug(f"[AgentEntity.run_agent] Received Message: {state_request}")
logger.debug(f"[AgentEntity.run] Received Message: {state_request}")
try:
# Build messages from conversation history, excluding error responses
@@ -150,7 +167,7 @@ class AgentEntity:
)
logger.debug(
"[AgentEntity.run_agent] Agent invocation completed - response type: %s",
"[AgentEntity.run] Agent invocation completed - response type: %s",
type(agent_run_response).__name__,
)
@@ -167,12 +184,12 @@ class AgentEntity:
state_response = DurableAgentStateResponse.from_run_response(correlation_id, agent_run_response)
self.state.data.conversation_history.append(state_response)
logger.debug("[AgentEntity.run_agent] AgentRunResponse stored in conversation history")
logger.debug("[AgentEntity.run] AgentRunResponse stored in conversation history")
return agent_run_response
except Exception as exc:
logger.exception("[AgentEntity.run_agent] Agent execution failed.")
logger.exception("[AgentEntity.run] Agent execution failed.")
# Create error message
error_message = ChatMessage(
@@ -367,7 +384,7 @@ def create_agent_entity(
operation = context.operation_name
if operation == "run_agent":
if operation == "run" or operation == "run_agent":
input_data: Any = context.get_input()
request: str | dict[str, Any]
@@ -377,7 +394,7 @@ def create_agent_entity(
# Fall back to treating input as message string
request = "" if input_data is None else str(cast(object, input_data))
result = await entity.run_agent(context, request)
result = await entity.run(context, request)
context.set_result(result.to_dict())
elif operation == "reset":
@@ -285,7 +285,7 @@ class DurableAIAgent(AgentProtocol):
logger.debug("[DurableAIAgent] Calling entity %s with message: %s", entity_id, message_str[:100])
# Call the entity to get the underlying task
entity_task = self.context.call_entity(entity_id, "run_agent", run_request.to_dict())
entity_task = self.context.call_entity(entity_id, "run", run_request.to_dict())
# Wrap it in an AgentTask that will convert the result to AgentRunResponse
agent_task = AgentTask(
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -29,7 +29,7 @@ docker run -d -p 10000:10000 -p 10001:10001 -p 10002:10002 mcr.microsoft.com/azu
**Durable Task Scheduler:**
```bash
docker run -d -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest
docker run -d -p 8080:8080 -p 8082:8082 -e DTS_USE_DYNAMIC_TASK_HUBS=true mcr.microsoft.com/dts/dts-emulator:latest
```
## Running Tests
@@ -338,7 +338,7 @@ class TestAgentEntityOperations:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context,
{"message": "Test message", "thread_id": "test-conv-123", "correlationId": "corr-app-entity-1"},
)
@@ -358,7 +358,7 @@ class TestAgentEntityOperations:
mock_context = Mock()
# Send first message
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-2"}
)
@@ -367,7 +367,7 @@ class TestAgentEntityOperations:
assert len(history) == 1 # Just the user message
# Send second message
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-2", "correlationId": "corr-app-entity-2b"}
)
@@ -398,12 +398,12 @@ class TestAgentEntityOperations:
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-app-entity-3a"}
)
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-app-entity-3b"}
)
assert len(entity.state.data.conversation_history) == 4
@@ -433,8 +433,36 @@ class TestAgentEntityFactory:
assert callable(entity_function)
def test_entity_function_handles_run_operation(self) -> None:
"""Test that the entity function handles the run operation."""
mock_agent = Mock()
mock_agent.run = AsyncMock(
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
)
entity_function = create_agent_entity(mock_agent)
# Mock context
mock_context = Mock()
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
"correlationId": "corr-app-factory-1",
}
mock_context.get_state.return_value = None
# Execute entity function
entity_function(mock_context)
# Verify result was set
assert mock_context.set_result.called
assert mock_context.set_state.called
result_call = mock_context.set_result.call_args[0][0]
assert "error" not in result_call
def test_entity_function_handles_run_agent_operation(self) -> None:
"""Test that the entity function handles the run_agent operation."""
"""Test that the entity function handles the deprecated run_agent operation for backward compatibility."""
mock_agent = Mock()
mock_agent.run = AsyncMock(
return_value=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response")])
@@ -458,6 +486,8 @@ class TestAgentEntityFactory:
# Verify result was set
assert mock_context.set_result.called
assert mock_context.set_state.called
result_call = mock_context.set_result.call_args[0][0]
assert "error" not in result_call
def test_entity_function_handles_reset_operation(self) -> None:
"""Test that the entity function handles the reset operation."""
@@ -585,7 +615,7 @@ class TestErrorHandling:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context, {"message": "Test message", "thread_id": "conv-1", "correlationId": "corr-app-error-1"}
)
@@ -605,7 +635,7 @@ class TestErrorHandling:
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run_agent"
mock_context.operation_name = "run"
mock_context.get_input.side_effect = Exception("Input error")
mock_context.get_state.return_value = None
@@ -108,6 +108,33 @@ class TestAgentEntityInit:
class TestAgentEntityRunAgent:
"""Test suite for the run_agent operation."""
async def test_run_executes_agent(self) -> None:
"""Test that run executes the agent."""
mock_agent = Mock()
mock_response = _agent_response("Test response")
mock_agent.run = AsyncMock(return_value=mock_response)
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run(
mock_context, {"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-1"}
)
# Verify agent.run was called
mock_agent.run.assert_called_once()
_, kwargs = mock_agent.run.call_args
sent_messages: list[Any] = kwargs.get("messages")
assert len(sent_messages) == 1
sent_message = sent_messages[0]
assert isinstance(sent_message, ChatMessage)
assert getattr(sent_message, "text", None) == "Test message"
assert getattr(sent_message.role, "value", sent_message.role) == "user"
# Verify result
assert isinstance(result, AgentRunResponse)
assert result.text == "Test response"
async def test_run_agent_executes_agent(self) -> None:
"""Test that run_agent executes the agent."""
mock_agent = Mock()
@@ -156,7 +183,7 @@ class TestAgentEntityRunAgent:
entity = AgentEntity(mock_agent, callback=callback)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context,
{
"message": "Tell me something",
@@ -203,7 +230,7 @@ class TestAgentEntityRunAgent:
entity = AgentEntity(mock_agent, callback=callback)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context,
{
"message": "Hi",
@@ -235,7 +262,7 @@ class TestAgentEntityRunAgent:
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
await entity.run(
mock_context, {"message": "User message", "thread_id": "conv-1", "correlationId": "corr-entity-2"}
)
@@ -263,17 +290,17 @@ class TestAgentEntityRunAgent:
assert len(entity.state.data.conversation_history) == 0
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-3a"}
)
assert len(entity.state.data.conversation_history) == 2
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-3b"}
)
assert len(entity.state.data.conversation_history) == 4
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-3c"}
)
assert len(entity.state.data.conversation_history) == 6
@@ -287,9 +314,7 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
with pytest.raises(ValueError, match="thread_id"):
await entity.run_agent(
mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"}
)
await entity.run(mock_context, {"message": "Message", "thread_id": None, "correlationId": "corr-entity-5"})
async def test_run_agent_multiple_conversations(self) -> None:
"""Test that run_agent maintains history across multiple messages."""
@@ -300,13 +325,13 @@ class TestAgentEntityRunAgent:
mock_context = Mock()
# Send multiple messages
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-8a"}
)
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-8b"}
)
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-8c"}
)
@@ -374,10 +399,10 @@ class TestAgentEntityReset:
mock_context = Mock()
# Have a conversation
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-10a"}
)
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-10b"}
)
@@ -413,7 +438,7 @@ class TestCreateAgentEntity:
# Mock context
mock_context = Mock()
mock_context.operation_name = "run_agent"
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-123",
@@ -576,7 +601,7 @@ class TestErrorHandling:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-1"}
)
@@ -595,7 +620,7 @@ class TestErrorHandling:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-2"}
)
@@ -614,7 +639,7 @@ class TestErrorHandling:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-error-3"}
)
@@ -631,7 +656,7 @@ class TestErrorHandling:
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run_agent"
mock_context.operation_name = "run"
mock_context.get_input.side_effect = Exception("Input error")
mock_context.get_state.return_value = None
@@ -651,7 +676,7 @@ class TestErrorHandling:
entity = AgentEntity(mock_agent)
mock_context = Mock()
result = await entity.run_agent(
result = await entity.run(
mock_context,
{"message": "Test message", "thread_id": "conv-123", "correlationId": "corr-entity-error-4"},
)
@@ -674,7 +699,7 @@ class TestConversationHistory:
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
await entity.run(
mock_context, {"message": "Message", "thread_id": "conv-1", "correlationId": "corr-entity-history-1"}
)
@@ -694,19 +719,19 @@ class TestConversationHistory:
# Send multiple messages with different responses
mock_agent.run = AsyncMock(return_value=_agent_response("Response 1"))
await entity.run_agent(
await entity.run(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-2a"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 2"))
await entity.run_agent(
await entity.run(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-2b"},
)
mock_agent.run = AsyncMock(return_value=_agent_response("Response 3"))
await entity.run_agent(
await entity.run(
mock_context,
{"message": "Message 3", "thread_id": "conv-1", "correlationId": "corr-entity-history-2c"},
)
@@ -729,11 +754,11 @@ class TestConversationHistory:
entity = AgentEntity(mock_agent)
mock_context = Mock()
await entity.run_agent(
await entity.run(
mock_context,
{"message": "Message 1", "thread_id": "conv-1", "correlationId": "corr-entity-history-3a"},
)
await entity.run_agent(
await entity.run(
mock_context,
{"message": "Message 2", "thread_id": "conv-1", "correlationId": "corr-entity-history-3b"},
)
@@ -766,7 +791,7 @@ class TestRunRequestSupport:
correlation_id="corr-runreq-1",
)
result = await entity.run_agent(mock_context, request)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
assert result.text == "Response"
@@ -787,7 +812,7 @@ class TestRunRequestSupport:
"correlationId": "corr-runreq-2",
}
result = await entity.run_agent(mock_context, request_dict)
result = await entity.run(mock_context, request_dict)
assert isinstance(result, AgentRunResponse)
assert result.text == "Response"
@@ -801,7 +826,7 @@ class TestRunRequestSupport:
mock_context = Mock()
with pytest.raises(ValueError):
await entity.run_agent(mock_context, "Simple message")
await entity.run(mock_context, "Simple message")
async def test_run_agent_stores_role_in_history(self) -> None:
"""Test that run_agent stores the role in conversation history."""
@@ -819,7 +844,7 @@ class TestRunRequestSupport:
correlation_id="corr-runreq-3",
)
await entity.run_agent(mock_context, request)
await entity.run(mock_context, request)
# Check that system role was stored
history = entity.state.data.conversation_history
@@ -842,7 +867,7 @@ class TestRunRequestSupport:
correlation_id="corr-runreq-4",
)
result = await entity.run_agent(mock_context, request)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
assert result.text == '{"answer": 42}'
@@ -860,7 +885,7 @@ class TestRunRequestSupport:
message="Test", thread_id="conv-runreq-5", enable_tool_calls=False, correlation_id="corr-runreq-5"
)
result = await entity.run_agent(mock_context, request)
result = await entity.run(mock_context, request)
assert isinstance(result, AgentRunResponse)
# Agent should have been called (tool disabling is framework-dependent)
@@ -874,7 +899,7 @@ class TestRunRequestSupport:
entity_function = create_agent_entity(mock_agent)
mock_context = Mock()
mock_context.operation_name = "run_agent"
mock_context.operation_name = "run"
mock_context.get_input.return_value = {
"message": "Test message",
"thread_id": "conv-789",
@@ -295,7 +295,7 @@ class TestDurableAIAgent:
call_args = mock_context.call_entity.call_args
entity_id, operation, request = call_args[0]
assert operation == "run_agent"
assert operation == "run"
assert request["message"] == "Test message"
assert request["enable_tool_calls"] is True
assert "correlationId" in request
+1 -1
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b251211"
version = "1.0.0b251216"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -34,7 +34,7 @@ from ._types import (
ToolMode,
)
from .exceptions import AgentExecutionException, AgentInitializationError
from .observability import use_agent_observability
from .observability import use_agent_instrumentation
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
@@ -516,8 +516,8 @@ class BaseAgent(SerializationMixin):
@use_agent_middleware
@use_agent_observability
class ChatAgent(BaseAgent):
@use_agent_instrumentation(capture_usage=False) # type: ignore[arg-type,misc]
class ChatAgent(BaseAgent): # type: ignore[misc]
"""A Chat Client Agent.
This is the primary agent implementation that uses a chat client to interact
@@ -583,7 +583,7 @@ class ChatAgent(BaseAgent):
print(update.text, end="")
"""
AGENT_SYSTEM_NAME: ClassVar[str] = "microsoft.agent_framework"
AGENT_PROVIDER_NAME: ClassVar[str] = "microsoft.agent_framework"
def __init__(
self,
@@ -878,6 +878,9 @@ class ChatAgent(BaseAgent):
user=user,
additional_properties=merged_additional_options, # type: ignore[arg-type]
)
# Ensure thread is forwarded in kwargs for tool invocation
kwargs["thread"] = thread
# Filter chat_options from kwargs to prevent duplicate keyword argument
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
response = await self.chat_client.get_response(
@@ -895,7 +898,12 @@ class ChatAgent(BaseAgent):
# Only notify the thread of new messages if the chatResponse was successful
# to avoid inconsistent messages state in the thread.
await self._notify_thread_of_new_messages(thread, input_messages, response.messages)
await self._notify_thread_of_new_messages(
thread,
input_messages,
response.messages,
**{k: v for k, v in kwargs.items() if k != "thread"},
)
return AgentRunResponse(
messages=response.messages,
response_id=response.response_id,
@@ -1017,6 +1025,8 @@ class ChatAgent(BaseAgent):
additional_properties=merged_additional_options, # type: ignore[arg-type]
)
# Ensure thread is forwarded in kwargs for tool invocation
kwargs["thread"] = thread
# Filter chat_options from kwargs to prevent duplicate keyword argument
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "chat_options"}
response_updates: list[ChatResponseUpdate] = []
@@ -1043,7 +1053,13 @@ class ChatAgent(BaseAgent):
response = ChatResponse.from_chat_response_updates(response_updates, output_format_type=co.response_format)
await self._update_thread_with_type_and_conversation_id(thread, response.conversation_id)
await self._notify_thread_of_new_messages(thread, input_messages, response.messages, **kwargs)
await self._notify_thread_of_new_messages(
thread,
input_messages,
response.messages,
**{k: v for k, v in kwargs.items() if k != "thread"},
)
@override
def get_new_thread(
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, Protocol, TypeVar, run
from pydantic import BaseModel
from ._logging import get_logger
from ._mcp import MCPTool
from ._memory import AggregateContextProvider, ContextProvider
from ._middleware import (
ChatMiddleware,
@@ -426,6 +425,8 @@ class BaseChatClient(SerializationMixin, ABC):
else [tools]
)
for tool in tools_list: # type: ignore[reportUnknownType]
from ._mcp import MCPTool
if isinstance(tool, MCPTool):
if not tool.is_connected:
await tool.connect()
+9 -1
View File
@@ -685,8 +685,16 @@ class MCPTool:
raise ToolExecutionException(
"Tools are not loaded for this server, please set load_tools=True in the constructor."
)
# Filter out framework kwargs that cannot be serialized by the MCP SDK.
# These are internal objects passed through the function invocation pipeline
# that should not be forwarded to external MCP servers.
filtered_kwargs = {
k: v for k, v in kwargs.items() if k not in {"chat_options", "tools", "tool_choice", "thread"}
}
try:
return _mcp_call_tool_result_to_ai_contents(await self.session.call_tool(tool_name, arguments=kwargs))
return _mcp_call_tool_result_to_ai_contents(
await self.session.call_tool(tool_name, arguments=filtered_kwargs)
)
except McpError as mcp_exc:
raise ToolExecutionException(mcp_exc.error.message, inner_exception=mcp_exc) from mcp_exc
except Exception as ex:
@@ -6,11 +6,13 @@ from abc import ABC, abstractmethod
from collections.abc import MutableSequence, Sequence
from contextlib import AsyncExitStack
from types import TracebackType
from typing import Any, Final, cast
from typing import TYPE_CHECKING, Any, Final, cast
from ._tools import ToolProtocol
from ._types import ChatMessage
if TYPE_CHECKING:
from ._tools import ToolProtocol
if sys.version_info >= (3, 12):
from typing import override # type: ignore # pragma: no cover
else:
@@ -54,7 +56,7 @@ class Context:
self,
instructions: str | None = None,
messages: Sequence[ChatMessage] | None = None,
tools: Sequence[ToolProtocol] | None = None,
tools: Sequence["ToolProtocol"] | None = None,
):
"""Create a new Context object.
@@ -65,7 +67,7 @@ class Context:
"""
self.instructions = instructions
self.messages: Sequence[ChatMessage] = messages or []
self.tools: Sequence[ToolProtocol] = tools or []
self.tools: Sequence["ToolProtocol"] = tools or []
# region ContextProvider
@@ -247,7 +249,7 @@ class AggregateContextProvider(ContextProvider):
contexts = await asyncio.gather(*[provider.invoking(messages, **kwargs) for provider in self.providers])
instructions: str = ""
return_messages: list[ChatMessage] = []
tools: list[ToolProtocol] = []
tools: list["ToolProtocol"] = []
for ctx in contexts:
if ctx.instructions:
instructions += ctx.instructions
@@ -1405,13 +1405,17 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
call_middleware = kwargs.pop("middleware", None)
instance_middleware = getattr(self, "middleware", None)
# Merge middleware from both sources, filtering for chat middleware only
all_middleware: list[ChatMiddleware | ChatMiddlewareCallable] = _merge_and_filter_chat_middleware(
instance_middleware, call_middleware
)
# Merge all middleware and separate by type
middleware = categorize_middleware(instance_middleware, call_middleware)
chat_middleware_list = middleware["chat"]
function_middleware_list = middleware["function"]
# If no middleware, use original method
if not all_middleware:
# Pass function middleware to function invocation system if present
if function_middleware_list:
kwargs["_function_middleware_pipeline"] = FunctionMiddlewarePipeline(function_middleware_list)
# If no chat middleware, use original method
if not chat_middleware_list:
async for update in original_get_streaming_response(self, messages, **kwargs):
yield update
return
@@ -1422,7 +1426,7 @@ def use_chat_middleware(chat_client_class: type[TChatClient]) -> type[TChatClien
# Extract chat_options or create default
chat_options = kwargs.pop("chat_options", ChatOptions())
pipeline = ChatMiddlewarePipeline(all_middleware) # type: ignore[arg-type]
pipeline = ChatMiddlewarePipeline(chat_middleware_list) # type: ignore[arg-type]
context = ChatContext(
chat_client=self,
messages=prepare_messages(messages),
@@ -1536,27 +1540,40 @@ def _merge_and_filter_chat_middleware(
return middleware["chat"] # type: ignore[return-value]
def extract_and_merge_function_middleware(chat_client: Any, **kwargs: Any) -> None:
def extract_and_merge_function_middleware(
chat_client: Any, kwargs: dict[str, Any]
) -> "FunctionMiddlewarePipeline | None":
"""Extract function middleware from chat client and merge with existing pipeline in kwargs.
Args:
chat_client: The chat client instance to extract middleware from.
kwargs: Dictionary containing middleware and pipeline information.
Keyword Args:
**kwargs: Dictionary containing middleware and pipeline information.
Returns:
A FunctionMiddlewarePipeline if function middleware is found, None otherwise.
"""
# Check if a pipeline was already created by use_chat_middleware
existing_pipeline: FunctionMiddlewarePipeline | None = kwargs.get("_function_middleware_pipeline")
# Get middleware sources
client_middleware = getattr(chat_client, "middleware", None) if hasattr(chat_client, "middleware") else None
run_level_middleware = kwargs.get("middleware")
existing_pipeline = kwargs.get("_function_middleware_pipeline")
# Extract existing pipeline middlewares if present
existing_middlewares = existing_pipeline._middlewares if existing_pipeline else None
# If we have an existing pipeline but no additional middleware sources, return it directly
if existing_pipeline and not client_middleware and not run_level_middleware:
return existing_pipeline
# If we have an existing pipeline with additional middleware, we need to merge
# Extract existing pipeline middlewares if present - cast to list[Middleware] for type compatibility
existing_middlewares: list[Middleware] | None = list(existing_pipeline._middlewares) if existing_pipeline else None
# Create combined pipeline from all sources using existing helper
combined_pipeline = create_function_middleware_pipeline(
client_middleware, run_level_middleware, existing_middlewares
)
if combined_pipeline:
kwargs["_function_middleware_pipeline"] = combined_pipeline
# If we have an existing pipeline but combined is None (no new middlewares), return existing
if existing_pipeline and combined_pipeline is None:
return existing_pipeline
return combined_pipeline
@@ -339,11 +339,17 @@ class SerializationMixin:
continue
# Handle dicts containing SerializationProtocol values
if isinstance(value, dict):
from datetime import date, datetime, time
serialized_dict: dict[str, Any] = {}
for k, v in value.items():
if isinstance(v, SerializationProtocol):
serialized_dict[k] = v.to_dict(exclude=exclude, exclude_none=exclude_none)
continue
# Convert datetime objects to strings
if isinstance(v, (datetime, date, time)):
serialized_dict[k] = str(v)
continue
# Check if the value is JSON serializable
if is_serializable(v):
serialized_dict[k] = v
+136 -46
View File
@@ -627,6 +627,12 @@ class AIFunction(BaseTool, Generic[ArgsT, ReturnT]):
self._invocation_duration_histogram = _default_histogram()
self.type: Literal["ai_function"] = "ai_function"
self._forward_runtime_kwargs: bool = False
if self.func:
sig = inspect.signature(self.func)
for param in sig.parameters.values():
if param.kind == inspect.Parameter.VAR_KEYWORD:
self._forward_runtime_kwargs = True
break
@property
def declaration_only(self) -> bool:
@@ -915,6 +921,7 @@ def _create_input_model_from_func(func: Callable[..., Any], name: str) -> type[B
)
for pname, param in sig.parameters.items()
if pname not in {"self", "cls"}
and param.kind not in {inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD}
}
return create_model(f"{name}_input", **fields) # type: ignore[call-overload, no-any-return]
@@ -1341,6 +1348,35 @@ class FunctionInvocationConfiguration(SerializationMixin):
self.include_detailed_errors = include_detailed_errors
class FunctionExecutionResult:
"""Internal wrapper pairing function output with loop control signals.
Function execution produces two distinct concerns: the semantic result (returned to
the LLM as FunctionResultContent) and control flow decisions (whether middleware
requested early termination). This wrapper keeps control signals out of user-facing
content types while allowing _try_execute_function_calls to communicate both.
Not exposed to users.
Attributes:
content: The FunctionResultContent or other content from the function execution.
terminate: If True, the function invocation loop should exit immediately without
another LLM call. Set when middleware sets context.terminate=True.
"""
__slots__ = ("content", "terminate")
def __init__(self, content: "Contents", terminate: bool = False) -> None:
"""Initialize FunctionExecutionResult.
Args:
content: The content from the function execution.
terminate: Whether to terminate the function calling loop.
"""
self.content = content
self.terminate = terminate
async def _auto_invoke_function(
function_call_content: "FunctionCallContent | FunctionApprovalResponseContent",
custom_args: dict[str, Any] | None = None,
@@ -1350,7 +1386,7 @@ async def _auto_invoke_function(
sequence_index: int | None = None,
request_index: int | None = None,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline
) -> "Contents":
) -> "FunctionExecutionResult | Contents":
"""Invoke a function call requested by the agent, applying middleware that is defined.
Args:
@@ -1365,7 +1401,8 @@ async def _auto_invoke_function(
middleware_pipeline: Optional middleware pipeline to apply during execution.
Returns:
A FunctionResultContent containing the result or exception.
A FunctionExecutionResult wrapping the content and terminate signal,
or a Contents object for approval/hosted tool scenarios.
Raises:
KeyError: If the requested function is not found in the tool map.
@@ -1385,10 +1422,12 @@ async def _auto_invoke_function(
# Tool should exist because _try_execute_function_calls validates this
if tool is None:
exc = KeyError(f'Function "{function_call_content.name}" not found.')
return FunctionResultContent(
call_id=function_call_content.call_id,
result=f'Error: Requested function "{function_call_content.name}" not found.',
exception=exc,
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
result=f'Error: Requested function "{function_call_content.name}" not found.',
exception=exc,
)
)
else:
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
@@ -1413,7 +1452,9 @@ async def _auto_invoke_function(
message = "Error: Argument parsing failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
)
if not middleware_pipeline or (
not hasattr(middleware_pipeline, "has_middlewares") and not middleware_pipeline.has_middlewares
@@ -1425,15 +1466,19 @@ async def _auto_invoke_function(
tool_call_id=function_call_content.call_id,
**runtime_kwargs if getattr(tool, "_forward_runtime_kwargs", False) else {},
)
return FunctionResultContent(
call_id=function_call_content.call_id,
result=function_result,
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
result=function_result,
)
)
except Exception as exc:
message = "Error: Function failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
)
# Execute through middleware pipeline if available
from ._middleware import FunctionInvocationContext
@@ -1457,15 +1502,20 @@ async def _auto_invoke_function(
context=middleware_context,
final_handler=final_function_handler,
)
return FunctionResultContent(
call_id=function_call_content.call_id,
result=function_result,
return FunctionExecutionResult(
content=FunctionResultContent(
call_id=function_call_content.call_id,
result=function_result,
),
terminate=middleware_context.terminate,
)
except Exception as exc:
message = "Error: Function failed."
if config.include_detailed_errors:
message = f"{message} Exception: {exc}"
return FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
return FunctionExecutionResult(
content=FunctionResultContent(call_id=function_call_content.call_id, result=message, exception=exc)
)
def _get_tool_map(
@@ -1496,7 +1546,7 @@ async def _try_execute_function_calls(
| Sequence[ToolProtocol | Callable[..., Any] | MutableMapping[str, Any]]",
config: FunctionInvocationConfiguration,
middleware_pipeline: Any = None, # Optional MiddlewarePipeline to avoid circular imports
) -> Sequence["Contents"]:
) -> tuple[Sequence["Contents"], bool]:
"""Execute multiple function calls concurrently.
Args:
@@ -1508,9 +1558,11 @@ async def _try_execute_function_calls(
middleware_pipeline: Optional middleware pipeline to apply during execution.
Returns:
A list of Contents containing the results of each function call,
or the approval requests if any function requires approval,
or the original function calls if any are declaration only.
A tuple of:
- A list of Contents containing the results of each function call,
or the approval requests if any function requires approval,
or the original function calls if any are declaration only.
- A boolean indicating whether to terminate the function calling loop.
"""
from ._types import FunctionApprovalRequestContent, FunctionCallContent
@@ -1533,17 +1585,20 @@ async def _try_execute_function_calls(
raise KeyError(f'Error: Requested function "{fcc.name}" not found.')
if approval_needed:
# approval can only be needed for Function Call Contents, not Approval Responses.
return [
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
for fcc in function_calls
if isinstance(fcc, FunctionCallContent)
]
return (
[
FunctionApprovalRequestContent(id=fcc.call_id, function_call=fcc)
for fcc in function_calls
if isinstance(fcc, FunctionCallContent)
],
False,
)
if declaration_only_flag:
# return the declaration only tools to the user, since we cannot execute them.
return [fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)]
return ([fcc for fcc in function_calls if isinstance(fcc, FunctionCallContent)], False)
# Run all function calls concurrently
return await asyncio.gather(*[
execution_results = await asyncio.gather(*[
_auto_invoke_function(
function_call_content=function_call, # type: ignore[arg-type]
custom_args=custom_args,
@@ -1556,6 +1611,20 @@ async def _try_execute_function_calls(
for seq_idx, function_call in enumerate(function_calls)
])
# Unpack FunctionExecutionResult wrappers and check for terminate signal
contents: list[Contents] = []
should_terminate = False
for result in execution_results:
if isinstance(result, FunctionExecutionResult):
contents.append(result.content)
if result.terminate:
should_terminate = True
else:
# Direct Contents (e.g., from hosted tools)
contents.append(result)
return (contents, should_terminate)
def _update_conversation_id(kwargs: dict[str, Any], conversation_id: str | None) -> None:
"""Update kwargs with conversation id.
@@ -1688,12 +1757,8 @@ def _handle_function_calls_response(
prepare_messages,
)
# Extract and merge function middleware from chat client with kwargs pipeline
extract_and_merge_function_middleware(self, **kwargs)
# Extract the middleware pipeline before calling the underlying function
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
# Extract and merge function middleware from chat client with kwargs
stored_middleware_pipeline = extract_and_merge_function_middleware(self, kwargs)
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
@@ -1719,7 +1784,7 @@ def _handle_function_calls_response(
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
if approved_responses:
approved_function_results = await _try_execute_function_calls(
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=approved_responses,
@@ -1727,6 +1792,7 @@ def _handle_function_calls_response(
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
approved_function_results = list(results)
if any(
fcr.exception is not None
for fcr in approved_function_results
@@ -1744,7 +1810,9 @@ def _handle_function_calls_response(
break
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
response = await func(self, messages=prepped_messages, **kwargs)
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
response = await func(self, messages=prepped_messages, **filtered_kwargs)
# if there are function calls, we will handle them first
function_results = {
it.call_id for it in response.messages[0].contents if isinstance(it, FunctionResultContent)
@@ -1764,7 +1832,7 @@ def _handle_function_calls_response(
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
function_call_results: list[Contents] = await _try_execute_function_calls(
function_call_results, should_terminate = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
@@ -1789,6 +1857,17 @@ def _handle_function_calls_response(
# the function calls are already in the response, so we just continue
return response
# Check if middleware signaled to terminate the loop (context.terminate=True)
# This allows middleware to short-circuit the tool loop without another LLM call
if should_terminate:
# Add tool results to response and return immediately without calling LLM again
result_message = ChatMessage(role="tool", contents=function_call_results)
response.messages.append(result_message)
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
return response
if any(
fcr.exception is not None
for fcr in function_call_results
@@ -1833,7 +1912,10 @@ def _handle_function_calls_response(
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
response = await func(self, messages=prepped_messages, **kwargs)
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
response = await func(self, messages=prepped_messages, **filtered_kwargs)
if fcc_messages:
for msg in reversed(fcc_messages):
response.messages.insert(0, msg)
@@ -1878,12 +1960,8 @@ def _handle_function_calls_streaming_response(
prepare_messages,
)
# Extract and merge function middleware from chat client with kwargs pipeline
extract_and_merge_function_middleware(self, **kwargs)
# Extract the middleware pipeline before calling the underlying function
# because the underlying function may not preserve it in kwargs
stored_middleware_pipeline = kwargs.get("_function_middleware_pipeline")
# Extract and merge function middleware from chat client with kwargs
stored_middleware_pipeline = extract_and_merge_function_middleware(self, kwargs)
# Get the config for function invocation (not part of ChatClientProtocol, hence getattr)
config: FunctionInvocationConfiguration | None = getattr(self, "function_invocation_configuration", None)
@@ -1902,7 +1980,7 @@ def _handle_function_calls_streaming_response(
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
approved_function_results: list[Contents] = []
if approved_responses:
approved_function_results = await _try_execute_function_calls(
results, _ = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=approved_responses,
@@ -1910,6 +1988,7 @@ def _handle_function_calls_streaming_response(
middleware_pipeline=stored_middleware_pipeline,
config=config,
)
approved_function_results = list(results)
if any(
fcr.exception is not None
for fcr in approved_function_results
@@ -1920,7 +1999,9 @@ def _handle_function_calls_streaming_response(
_replace_approval_contents_with_results(prepped_messages, fcc_todo, approved_function_results)
all_updates: list["ChatResponseUpdate"] = []
async for update in func(self, messages=prepped_messages, **kwargs):
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
all_updates.append(update)
yield update
@@ -1962,7 +2043,7 @@ def _handle_function_calls_streaming_response(
if function_calls and tools:
# Use the stored middleware pipeline instead of extracting from kwargs
# because kwargs may have been modified by the underlying function
function_call_results: list[Contents] = await _try_execute_function_calls(
function_call_results, should_terminate = await _try_execute_function_calls(
custom_args=kwargs,
attempt_idx=attempt_idx,
function_calls=function_calls,
@@ -1991,6 +2072,13 @@ def _handle_function_calls_streaming_response(
# the function calls were already yielded.
return
# Check if middleware signaled to terminate the loop (context.terminate=True)
# This allows middleware to short-circuit the tool loop without another LLM call
if should_terminate:
# Yield tool results and return immediately without calling LLM again
yield ChatResponseUpdate(contents=function_call_results, role="tool")
return
if any(
fcr.exception is not None
for fcr in function_call_results
@@ -2031,7 +2119,9 @@ def _handle_function_calls_streaming_response(
# Failsafe: give up on tools, ask model for plain answer
kwargs["tool_choice"] = "none"
async for update in func(self, messages=prepped_messages, **kwargs):
# Filter out internal framework kwargs before passing to clients.
filtered_kwargs = {k: v for k, v in kwargs.items() if k != "thread"}
async for update in func(self, messages=prepped_messages, **filtered_kwargs):
yield update
return streaming_function_invocation_wrapper
@@ -1816,13 +1816,14 @@ def prepare_function_call_results(content: Contents | Any | list[Contents | Any]
"""Prepare the values of the function call results."""
if isinstance(content, Contents):
# For BaseContent objects, use to_dict and serialize to JSON
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}))
# Use default=str to handle datetime and other non-JSON-serializable objects
return json.dumps(content.to_dict(exclude={"raw_representation", "additional_properties"}), default=str)
dumpable = _prepare_function_call_results_as_dumpable(content)
if isinstance(dumpable, str):
return dumpable
# fallback
return json.dumps(dumpable)
# fallback - use default=str to handle datetime and other non-JSON-serializable objects
return json.dumps(dumpable, default=str)
# region Chat Response constants
@@ -13,20 +13,25 @@ from agent_framework import (
AgentRunResponseUpdate,
AgentThread,
BaseAgent,
BaseContent,
ChatMessage,
Contents,
FunctionApprovalRequestContent,
FunctionApprovalResponseContent,
FunctionCallContent,
FunctionResultContent,
Role,
TextContent,
UsageDetails,
)
from ..exceptions import AgentExecutionException
from ._checkpoint import CheckpointStorage
from ._events import (
AgentRunUpdateEvent,
RequestInfoEvent,
WorkflowEvent,
WorkflowOutputEvent,
)
from ._message_utils import normalize_messages_input
from ._typing_utils import is_type_compatible
@@ -117,6 +122,8 @@ class WorkflowAgent(BaseAgent):
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AgentRunResponse:
"""Get a response from the workflow agent (non-streaming).
@@ -124,10 +131,16 @@ class WorkflowAgent(BaseAgent):
This method collects all streaming updates and merges them into a single response.
Args:
messages: The message(s) to send to the workflow.
messages: The message(s) to send to the workflow. Required for new runs,
should be None when resuming from checkpoint.
Keyword Args:
thread: The conversation thread. If None, a new thread will be created.
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
resumes from this checkpoint instead of starting fresh.
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
used to load and restore the checkpoint. When provided without checkpoint_id,
enables checkpointing for this run.
**kwargs: Additional keyword arguments.
Returns:
@@ -139,7 +152,9 @@ class WorkflowAgent(BaseAgent):
thread = thread or self.get_new_thread()
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(input_messages, response_id):
async for update in self._run_stream_impl(
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
):
response_updates.append(update)
# Convert updates to final response.
@@ -155,15 +170,23 @@ class WorkflowAgent(BaseAgent):
messages: str | ChatMessage | list[str] | list[ChatMessage] | None = None,
*,
thread: AgentThread | None = None,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
**kwargs: Any,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Stream response updates from the workflow agent.
Args:
messages: The message(s) to send to the workflow.
messages: The message(s) to send to the workflow. Required for new runs,
should be None when resuming from checkpoint.
Keyword Args:
thread: The conversation thread. If None, a new thread will be created.
checkpoint_id: ID of checkpoint to restore from. If provided, the workflow
resumes from this checkpoint instead of starting fresh.
checkpoint_storage: Runtime checkpoint storage. When provided with checkpoint_id,
used to load and restore the checkpoint. When provided without checkpoint_id,
enables checkpointing for this run.
**kwargs: Additional keyword arguments.
Yields:
@@ -174,7 +197,9 @@ class WorkflowAgent(BaseAgent):
response_updates: list[AgentRunResponseUpdate] = []
response_id = str(uuid.uuid4())
async for update in self._run_stream_impl(input_messages, response_id):
async for update in self._run_stream_impl(
input_messages, response_id, thread, checkpoint_id, checkpoint_storage
):
response_updates.append(update)
yield update
@@ -188,12 +213,18 @@ class WorkflowAgent(BaseAgent):
self,
input_messages: list[ChatMessage],
response_id: str,
thread: AgentThread,
checkpoint_id: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
) -> AsyncIterable[AgentRunResponseUpdate]:
"""Internal implementation of streaming execution.
Args:
input_messages: Normalized input messages to process.
response_id: The unique response ID for this workflow execution.
thread: The conversation thread containing message history.
checkpoint_id: ID of checkpoint to restore from.
checkpoint_storage: Runtime checkpoint storage.
Yields:
AgentRunResponseUpdate objects representing the workflow execution progress.
@@ -217,10 +248,27 @@ class WorkflowAgent(BaseAgent):
# and we will let the workflow to handle this -- the agent does not
# have an opinion on this.
event_stream = self.workflow.send_responses_streaming(function_responses)
elif checkpoint_id is not None:
# Resume from checkpoint - don't prepend thread history since workflow state
# is being restored from the checkpoint
event_stream = self.workflow.run_stream(
message=None,
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
)
else:
# Execute workflow with streaming (initial run or no function responses)
# Pass the new input messages directly to the workflow
event_stream = self.workflow.run_stream(input_messages)
# Build the complete conversation by prepending thread history to input messages
conversation_messages: list[ChatMessage] = []
if thread.message_store:
history = await thread.message_store.list_messages()
if history:
conversation_messages.extend(history)
conversation_messages.extend(input_messages)
event_stream = self.workflow.run_stream(
message=conversation_messages,
checkpoint_storage=checkpoint_storage,
)
# Process events from the stream
async for event in event_stream:
@@ -236,9 +284,8 @@ class WorkflowAgent(BaseAgent):
) -> AgentRunResponseUpdate | None:
"""Convert a workflow event to an AgentRunResponseUpdate.
Only AgentRunUpdateEvent and RequestInfoEvent are processed.
Other workflow events are ignored as they are workflow-internal and should
have corresponding AgentRunUpdateEvent emissions if relevant to agent consumers.
AgentRunUpdateEvent, RequestInfoEvent, and WorkflowOutputEvent are processed.
Other workflow events are ignored as they are workflow-internal.
"""
match event:
case AgentRunUpdateEvent(data=update):
@@ -247,6 +294,42 @@ class WorkflowAgent(BaseAgent):
return update
return None
case WorkflowOutputEvent(data=data, source_executor_id=source_executor_id):
# Convert workflow output to an agent response update.
# Handle different data types appropriately.
if isinstance(data, AgentRunResponseUpdate):
# Already an update, pass through
return data
if isinstance(data, ChatMessage):
# Convert ChatMessage to update
return AgentRunResponseUpdate(
contents=list(data.contents),
role=data.role,
author_name=data.author_name or source_executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
# Determine contents based on data type
if isinstance(data, BaseContent):
# Already a content type (TextContent, ImageContent, etc.)
contents: list[Contents] = [cast(Contents, data)]
elif isinstance(data, str):
contents = [TextContent(text=data)]
else:
# Fallback: convert to string representation
contents = [TextContent(text=str(data))]
return AgentRunResponseUpdate(
contents=contents,
role=Role.ASSISTANT,
author_name=source_executor_id,
response_id=response_id,
message_id=str(uuid.uuid4()),
created_at=datetime.now(tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
raw_representation=data,
)
case RequestInfoEvent(request_id=request_id):
# Store the pending request for later correlation
self.pending_requests[request_id] = event
@@ -11,6 +11,7 @@ from .._agents import AgentProtocol, ChatAgent
from .._threads import AgentThread
from .._types import AgentRunResponse, AgentRunResponseUpdate, ChatMessage
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._conversation_state import encode_chat_messages
from ._events import (
AgentRunEvent,
@@ -105,6 +106,11 @@ class AgentExecutor(Executor):
return [AgentRunResponse]
return []
@property
def description(self) -> str | None:
"""Get the description of the underlying agent."""
return self._agent.description
@handler
async def run(
self, request: AgentExecutorRequest, ctx: WorkflowContext[AgentExecutorResponse, AgentRunResponse]
@@ -304,9 +310,12 @@ class AgentExecutor(Executor):
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
response = await self._agent.run(
self._cache,
thread=self._agent_thread,
**run_kwargs,
)
await ctx.add_event(AgentRunEvent(self.id, response))
@@ -328,11 +337,14 @@ class AgentExecutor(Executor):
Returns:
The complete AgentRunResponse, or None if waiting for user input.
"""
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentRunResponseUpdate] = []
user_input_requests: list[FunctionApprovalRequestContent] = []
async for update in self._agent.run_stream(
self._cache,
thread=self._agent_thread,
**run_kwargs,
):
updates.append(update)
await ctx.add_event(AgentRunUpdateEvent(self.id, update))
@@ -3,7 +3,6 @@
import asyncio
import inspect
import logging
import uuid
from collections.abc import Callable, Sequence
from typing import Any
@@ -30,7 +29,8 @@ parallel workflow with:
- a default aggregator that combines all agent conversations and completes the workflow
Notes:
- Participants should be AgentProtocol instances or Executors.
- Participants can be provided as AgentProtocol or Executor instances via `.participants()`,
or as factories returning AgentProtocol or Executor via `.register_participants()`.
- A custom aggregator can be provided as:
- an Executor instance (it should handle list[AgentExecutorResponse],
yield output), or
@@ -396,7 +396,7 @@ class ConcurrentBuilder:
| Callable[[list[AgentExecutorResponse]], Any]
| Callable[[list[AgentExecutorResponse], WorkflowContext[Never, Any]], Any],
) -> "ConcurrentBuilder":
r"""Override the default aggregator with an executor, an executor factory, or a callback.
r"""Override the default aggregator with an executor or a callback.
- Executor: must handle `list[AgentExecutorResponse]` and yield output using `ctx.yield_output(...)`
- Callback: sync or async callable with one of the signatures:
@@ -521,52 +521,30 @@ class ConcurrentBuilder:
)
)
builder = WorkflowBuilder()
participants: list[Executor | AgentProtocol] = []
if self._participant_factories:
# Register executors/agents to avoid warnings from the workflow builder
# if factories are provided instead of direct instances. This doesn't
# break the factory pattern since the concurrent builder still creates
# new instances per workflow build.
factory_names: list[str] = []
# Resolve the participant factories now. This doesn't break the factory pattern
# since the Concurrent builder still creates new instances per workflow build.
for factory in self._participant_factories:
factory_name = uuid.uuid4().hex
factory_names.append(factory_name)
instance = factory()
if isinstance(instance, Executor):
builder.register_executor(lambda executor=instance: executor, name=factory_name) # type: ignore[misc]
else:
builder.register_agent(lambda agent=instance: agent, name=factory_name) # type: ignore[misc]
# Register the dispatcher and the aggregator
builder.register_executor(lambda: dispatcher, name="dispatcher")
builder.register_executor(lambda: aggregator, name="aggregator")
builder.set_start_executor("dispatcher")
builder.add_fan_out_edges("dispatcher", factory_names)
if self._request_info_enabled:
# Insert interceptor between fan-in and aggregator
# participants -> fan-in -> interceptor -> aggregator
builder.register_executor(
lambda: RequestInfoInterceptor(executor_id="request_info"),
name="request_info_interceptor",
)
builder.add_fan_in_edges(factory_names, "request_info_interceptor")
builder.add_edge("request_info_interceptor", "aggregator")
else:
# Direct fan-in to aggregator
builder.add_fan_in_edges(factory_names, "aggregator")
p = factory()
participants.append(p)
else:
builder.set_start_executor(dispatcher)
builder.add_fan_out_edges(dispatcher, self._participants)
participants = self._participants
builder = WorkflowBuilder()
builder.set_start_executor(dispatcher)
builder.add_fan_out_edges(dispatcher, participants)
if self._request_info_enabled:
# Insert interceptor between fan-in and aggregator
# participants -> fan-in -> interceptor -> aggregator
request_info_interceptor = RequestInfoInterceptor(executor_id="request_info")
builder.add_fan_in_edges(participants, request_info_interceptor)
builder.add_edge(request_info_interceptor, aggregator)
else:
# Direct fan-in to aggregator
builder.add_fan_in_edges(participants, aggregator)
if self._request_info_enabled:
# Insert interceptor between fan-in and aggregator
# participants -> fan-in -> interceptor -> aggregator
request_info_interceptor = RequestInfoInterceptor(executor_id="request_info")
builder.add_fan_in_edges(self._participants, request_info_interceptor)
builder.add_edge(request_info_interceptor, aggregator)
else:
# Direct fan-in to aggregator
builder.add_fan_in_edges(self._participants, aggregator)
if self._checkpoint_storage is not None:
builder = builder.with_checkpointing(self._checkpoint_storage)
@@ -9,6 +9,11 @@ EXECUTOR_STATE_KEY = "_executor_state"
# Source identifier for internal workflow messages.
INTERNAL_SOURCE_PREFIX = "internal"
# SharedState key for storing run kwargs that should be passed to agent invocations.
# Used by all orchestration patterns (Sequential, Concurrent, GroupChat, Handoff, Magentic)
# to pass kwargs from workflow.run_stream() through to agent.run_stream() and @ai_function tools.
WORKFLOW_RUN_KWARGS_KEY = "_workflow_run_kwargs"
def INTERNAL_SOURCE_ID(executor_id: str) -> str:
"""Generate an internal source ID for a given executor."""
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,7 @@ from agent_framework import (
from ._base_group_chat_orchestrator import BaseGroupChatOrchestrator
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._const import EXECUTOR_STATE_KEY, WORKFLOW_RUN_KWARGS_KEY
from ._events import AgentRunUpdateEvent, WorkflowEvent
from ._executor import Executor, handler
from ._group_chat import (
@@ -286,12 +286,14 @@ class _MagenticStartMessage(DictConvertible):
"""Internal: A message to start a magentic workflow."""
messages: list[ChatMessage] = field(default_factory=_new_chat_message_list)
run_kwargs: dict[str, Any] = field(default_factory=dict)
def __init__(
self,
messages: str | ChatMessage | Sequence[str] | Sequence[ChatMessage] | None = None,
*,
task: ChatMessage | None = None,
run_kwargs: dict[str, Any] | None = None,
) -> None:
normalized = normalize_messages_input(messages)
if task is not None:
@@ -299,6 +301,7 @@ class _MagenticStartMessage(DictConvertible):
if not normalized:
raise ValueError("MagenticStartMessage requires at least one message input.")
self.messages: list[ChatMessage] = normalized
self.run_kwargs: dict[str, Any] = run_kwargs or {}
@property
def task(self) -> ChatMessage:
@@ -1179,6 +1182,10 @@ class MagenticOrchestratorExecutor(BaseGroupChatOrchestrator):
return
logger.info("Magentic Orchestrator: Received start message")
# Store run_kwargs in SharedState so agent executors can access them
# Always store (even empty dict) so retrieval is deterministic
await context.set_shared_state(WORKFLOW_RUN_KWARGS_KEY, message.run_kwargs or {})
self._context = MagenticContext(
task=message.task,
participant_descriptions=self._participants,
@@ -2004,10 +2011,12 @@ class MagenticAgentExecutor(Executor):
"""
logger.debug(f"Agent {self._agent_id}: Running with {len(self._chat_history)} messages")
run_kwargs: dict[str, Any] = await ctx.get_shared_state(WORKFLOW_RUN_KWARGS_KEY)
updates: list[AgentRunResponseUpdate] = []
# The wrapped participant is guaranteed to be an BaseAgent when this is called.
agent = cast("AgentProtocol", self._agent)
async for update in agent.run_stream(messages=self._chat_history): # type: ignore[attr-defined]
async for update in agent.run_stream(messages=self._chat_history, **run_kwargs): # type: ignore[attr-defined]
updates.append(update)
await self._emit_agent_delta_event(ctx, update)
@@ -2604,38 +2613,48 @@ class MagenticWorkflow:
"""Access the underlying workflow."""
return self._workflow
async def run_streaming_with_string(self, task_text: str) -> AsyncIterable[WorkflowEvent]:
async def run_streaming_with_string(self, task_text: str, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with a task string.
Args:
task_text: The task description as a string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Yields:
WorkflowEvent: The events generated during the workflow execution.
"""
start_message = _MagenticStartMessage.from_string(task_text)
start_message.run_kwargs = kwargs
async for event in self._workflow.run_stream(start_message):
yield event
async def run_streaming_with_message(self, task_message: ChatMessage) -> AsyncIterable[WorkflowEvent]:
async def run_streaming_with_message(
self, task_message: ChatMessage, **kwargs: Any
) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with a ChatMessage.
Args:
task_message: The task as a ChatMessage.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Yields:
WorkflowEvent: The events generated during the workflow execution.
"""
start_message = _MagenticStartMessage(task_message)
start_message = _MagenticStartMessage(task_message, run_kwargs=kwargs)
async for event in self._workflow.run_stream(start_message):
yield event
async def run_stream(self, message: Any | None = None) -> AsyncIterable[WorkflowEvent]:
async def run_stream(self, message: Any | None = None, **kwargs: Any) -> AsyncIterable[WorkflowEvent]:
"""Run the workflow with either a message object or the preset task string.
Args:
message: The message to send. If None and task_text was provided during construction,
uses the preset task string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
These kwargs will be available in @ai_function tools via **kwargs.
Example: workflow.run_stream("task", user_id="123", custom_data={...})
Yields:
WorkflowEvent: The events generated during the workflow execution.
@@ -2643,13 +2662,19 @@ class MagenticWorkflow:
if message is None:
if self._task_text is None:
raise ValueError("No message provided and no preset task text available")
message = _MagenticStartMessage.from_string(self._task_text)
start_message = _MagenticStartMessage.from_string(self._task_text)
elif isinstance(message, str):
message = _MagenticStartMessage.from_string(message)
start_message = _MagenticStartMessage.from_string(message)
elif isinstance(message, (ChatMessage, list)):
message = _MagenticStartMessage(message) # type: ignore[arg-type]
start_message = _MagenticStartMessage(message) # type: ignore[arg-type]
else:
start_message = message
async for event in self._workflow.run_stream(message):
# Attach kwargs to the start message
if isinstance(start_message, _MagenticStartMessage):
start_message.run_kwargs = kwargs
async for event in self._workflow.run_stream(start_message):
yield event
async def _validate_checkpoint_participants(
@@ -2730,46 +2755,49 @@ class MagenticWorkflow:
f"Missing names: {missing}; unexpected names: {unexpected}."
)
async def run_with_string(self, task_text: str) -> WorkflowRunResult:
async def run_with_string(self, task_text: str, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow with a task string and return all events.
Args:
task_text: The task description as a string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_streaming_with_string(task_text):
async for event in self.run_streaming_with_string(task_text, **kwargs):
events.append(event)
return WorkflowRunResult(events)
async def run_with_message(self, task_message: ChatMessage) -> WorkflowRunResult:
async def run_with_message(self, task_message: ChatMessage, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow with a ChatMessage and return all events.
Args:
task_message: The task as a ChatMessage.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_streaming_with_message(task_message):
async for event in self.run_streaming_with_message(task_message, **kwargs):
events.append(event)
return WorkflowRunResult(events)
async def run(self, message: Any | None = None) -> WorkflowRunResult:
async def run(self, message: Any | None = None, **kwargs: Any) -> WorkflowRunResult:
"""Run the workflow and return all events.
Args:
message: The message to send. If None and task_text was provided during construction,
uses the preset task string.
**kwargs: Additional keyword arguments to pass through to agent invocations.
Returns:
WorkflowRunResult: All events generated during the workflow execution.
"""
events: list[WorkflowEvent] = []
async for event in self.run_stream(message):
async for event in self.run_stream(message, **kwargs):
events.append(event)
return WorkflowRunResult(events)

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