Compare commits

...
Author SHA1 Message Date
Evan MattsonandGitHub d81b579111 Python: Bump ag-ui package to 1.0.0b251105 for a release. Update changelog. (#1922)
* Bump ag-ui package to 1.0.0b251105 for a release. Update changelog.

* Fix authors and license-files
2025-11-05 08:30:52 +00:00
Evan MattsonandGitHub 35a8565495 Python: AG-UI protocol support (#1826)
* Add AG-UI integration

* Fix tests. PR feedback

* Cleanup

* PR Feedback

* Improve README and getting started experience

* Fix links
2025-11-05 05:25:24 +00:00
0c862e97a6 Python: feat: Add ChatKit integration with a sample application (#1273)
* feat: Add ChatKit integration with a new frontend application

- Created a new frontend application using React and Vite for the ChatKit integration.
- Added essential files including package.json, vite.config.ts, and Tailwind CSS configuration.
- Implemented core components: App, Home, ChatKitPanel, ThemeToggle, and hooks for color scheme management.
- Established SQLite-based store implementation for ChatKit data persistence in store.py.
- Integrated theme toggling functionality for light and dark modes.
- Set up ESLint and TypeScript configurations for better development experience.

* git ignore

* fix mypy

* add mising file

* minimal frontend for chatkit sample

* update ignore files

* version

* set python version lowerbound on chatkit

* update project settings for chatkit

* update setup

* update setup

* update setup

* update setup

* weather widget

* add select city widget sample

* remove widget helper

* update chatkit to include file attachments and cover more thread item types

* update readme with mermaid diagram

* update diagram

* update instructions

* update chatkit dependency

* fix converter imports

* move to demos/

* move to demos/ -- rename references

* support multiple session instead of using global variable in sample

* support chunk streaming

* fix tests

* Update python/samples/demos/chatkit-integration/store.py

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

* use local host

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2025-11-05 02:11:40 +00:00
Peter IbekweandGitHub bbde248839 .NET: Add unit tests for CreateConversation executor (#1915)
* Add unit tests for create conversation executor

* Update indentation and comment typo.
2025-11-05 01:40:10 +00:00
Tao ChenandGitHub 552f7c781d .NET: Make sure Workflow activities are as expected (#1903)
* Make sure Workflow activities are as expected

* misc

* Copliot comments

* Fix unit tests

* Improve test stability

* Fix unit tests

* Fix formatting
2025-11-05 00:37:31 +00:00
Evan MattsonandGitHub 2499262f30 Add orchestration samples link (#1914) 2025-11-05 00:30:56 +00:00
f415959d33 .NET: Add Writer-Critic Iterative Refinement Workflow Sample (#1790)
* Adding Sample for writer-critic workflow implemented using Worfklow, custom executors, agents, switch, custom states, different entry points for the executors.

* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs

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

* Update dotnet/samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/Program.cs

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

* using now structured output, with streaming for UX responsiveness.

* improved comments and order, so comments directly precede what they're describing

* fixing issue with internal class that the analyzer doesn't recognize that CriticDecision is instantiated, just indirectly via JSON deserialization

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-11-04 23:50:41 +00:00
Evan MattsonandGitHub 7b3e2a7e82 Update changelog with a new PR that went in (#1912) 2025-11-04 23:20:13 +00:00
Evan MattsonandGitHub d5040236c9 Fix mcp tool cloning for handoff pattern (#1883) 2025-11-04 22:49:34 +00:00
Dmytro StrukandGitHub 9e1b3c9b85 Python: .NET: Updated package version and small fix (#1911)
* Removed public key

* Updated package version

* Updated Python package versions
2025-11-04 22:42:07 +00:00
westeyandGitHub 8b4aa1ebb5 .NET: Add additional error handling to existing providers. (#1837)
* Add additional error handling to existing providers.

* Add additional information when logging mem0 messages.

* Fix formatting.
2025-11-04 17:09:45 +00:00
westeyandGitHub ada5b83c80 .NET: Add rag samples with sample TextSearchStore (#1664)
* Port store for adding text to a vector store to AF

* Fix typo.

* Change TextSearchStore to sample, and add sample to use it and do rag with a custom schema

* Add more tests and fix broken ones

* Fix merge issue

* Fix sample after merge.

* Convert TextSearchStore to use Dynamic mode to be AOT compatible.

* Add some more clarification on when to use assistant messages in rag searches.
2025-11-04 16:24:26 +00:00
64fc3f381f .NET: Response & foundry agent hosted MCP sample (#1568)
* Adding sample demonstrating hosted MCP with Responses

* Add mcp readme.md to slnx

* Update FoundryAgent sample to use MCP types from abstraction and to show how to do approval

* Fix param name after package update.

* Fix environment variable name for consistency

* Apply suggestion from @Copilot

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

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-04 15:54:20 +00:00
Evan MattsonandGitHub 50d9b13bfc Python: [BREAKING] consolidate workflow run APIs (#1723)
* consolidate workflow run apis

* improve validation, add tests

* Proper code tags for docs

* Update sample output

* Remove cycle validation

* PR feedback

* Validation

* Cleanup
2025-11-03 23:34:59 +00:00
Tao ChenandGitHub b0ee7028a6 .NET: Add workflow as an agent with observability sample (#1612)
* Add workflow as an agent with observability sample

* Address comment

* Fix formatting

* enable sensitive data

* enable sensitive data for sub agents

* adjust aggregator handlers
2025-11-03 20:36:33 +00:00
Eduard van ValkenburgandGitHub 12d17acdc0 Python: Introducing the Anthropic Client (#1819)
* initial version of anthropic connector

* updated implementation and added tests

* fix type and readme

* mypy fix and int tests enabled

* add integration test setup

* updated based on comments

* improved function result handling

* added extra unordered test

* updated from review

* fix tool choice handling

* same fix for chat client
2025-11-03 19:32:28 +00:00
Giles OdigweandGitHub a766a81243 a2a workflows fix (#1860) 2025-11-03 18:47:09 +00:00
Eduard van ValkenburgandGitHub e462d209fd Python: fix middleware and cleanup confusing function (#1865)
* fix middleware and cleanup redundant function

* added test to validate
2025-11-03 18:42:59 +00:00
0b843d2b3e .NET: Add simple rag sample for catalog (#1834)
* add simple rag sample for catalog

* Update dotnet/samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 18:25:16 +00:00
Jacob AlberandGitHub b25b0af49b .NET: [BREAKING] Unify ExecutorIsh and ExecutorRegistration, unify/simplify APIs (#1637)
* refactor: Unify ExecutorIsh and ExecutorRegistration => ExecutorBinding

* Switch to more modern Record type-tree for Sum Types
* Unify APIs for getting ExecutorBinding
* Fix an issue where workflows consisting entirely of cross-run shareable executors which are not instance-resettable do not properly clear state when running non-concurrently.

* feat: Simplify function-to-executor pattern

* refactor: Normalize API naming
2025-11-03 18:20:35 +00:00
91c66f8a2c .NET: Sample demonstrating the use of agents in workflows (#1836)
* add sample demonstrating the use of agents in workflows

* Update dotnet/samples/Catalog/AgentsInWorkflows/README.md

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

* use type instead of var

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 17:53:38 +00:00
c83011b30d .NET: Peibekwe/workflows cancellation token fix (#1740)
* Propagate cancellation token down the stack

* Added unit tests to cover workflow cancellation scenarios

* Updated tests based on feedback to simplify assert.

* Create custom AsyncEnumrable to gracefully handle cancellation for Channel reader. Tailor cancellation tests to declarative scenarios.

* Update comment and naming for readability.

* Fixing minor stylistic recommendation.

---------

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-11-03 16:34:55 +00:00
e87eed573b .NET: Change GetAIAgentAsync to be synchronous (#1846)
* Change GetAIAgentAsync to be synchronous

* Update dotnet/tests/Microsoft.Agents.AI.A2A.UnitTests/Extensions/A2AAgentCardExtensionsTests.cs

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-03 15:16:19 +00:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>stephentoub
b467ddf92a Update Microsoft.Extensions.AI libraries to 9.10.2 and OpenAI to 2.6.0 (#1859)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
2025-11-03 13:09:44 +00:00
Giles OdigweandGitHub a9a1b1e567 Python: Azure AI top_p and temperature parameters fix (#1839)
* azure ai parameters fix

* added unit tests

* unit test fix

* fix
2025-10-31 20:46:38 +00:00
a5db641ce3 .NET: Background responses sample (#1831)
* add samples that demonstrate background responses with function calling and agent state persistence

* update readme

* update sample description

* rename sample project

* remove unnecessary namespace and ysed task delay instead thread sleep generated by ai

* change names

* Update dotnet/samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/AgentStateStore.cs

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

* simplify sample and address pr review comments

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 17:13:43 +00:00
889f45a7ef Python: Ensure agent thread is part of checkpoint (#1756)
* ensure agent thread is part of checkpoint

* Update python/packages/core/agent_framework/_workflows/_agent_executor.py

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

* remove data copying for server side thread.

* refine warning check

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 15:48:43 +00:00
Tao ChenandGitHub 68b6a55757 [BREAKING] Python: Remove request_type param from ctx.request_info() (#1824)
* Remove request_type param from ctx.request_info()

* Address comments
2025-10-31 14:31:15 +00:00
westeyandGitHub 2101d9d36d Don't stamp unknown author name on output messages. (#1830) 2025-10-31 14:12:20 +00:00
Eric ZhuandGitHub 1543370027 Python: Lab: Updates to GAIA module (#1763)
* Lab: Updates to GAIA module

* update

* emoj!

* fix lint

* update lab test workflow to only trigger for python changes

* lint

* lint

* Fix broken OpenAI agents JS documentation link
2025-10-30 22:02:31 +00:00
7431b46bf0 Add community readme (#1817)
* Add community readme

* Update COMMUNITY.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-30 20:29:01 +00:00
westeyandGitHub 947e9eb1dc Fix broken .md link (#1818) 2025-10-30 20:03:29 +00:00
ChrisandGitHub b9d3e51734 Clean-up (#1810) 2025-10-30 17:49:31 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
42f20b431c Bump OpenTelemetry.Instrumentation.AspNetCore from 1.12.0 to 1.13.0 (#1802)
---
updated-dependencies:
- dependency-name: OpenTelemetry.Instrumentation.AspNetCore
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-10-30 16:52:41 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
a24624e28e Bump Microsoft.Identity.Client.Extensions.Msal from 4.77.1 to 4.78.0 (#1798)
---
updated-dependencies:
- dependency-name: Microsoft.Identity.Client.Extensions.Msal
  dependency-version: 4.78.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-10-30 16:27:41 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
6e49c7a7b7 Bump OpenTelemetry.Extensions.Hosting from 1.12.0 to 1.13.1 (#1800)
---
updated-dependencies:
- dependency-name: OpenTelemetry.Extensions.Hosting
  dependency-version: 1.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-10-30 16:26:33 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Chris
23b76b382f Bump OpenTelemetry.Instrumentation.Http from 1.12.0 to 1.13.0 (#1801)
---
updated-dependencies:
- dependency-name: OpenTelemetry.Instrumentation.Http
  dependency-version: 1.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
2025-10-30 16:26:14 +00:00
Eduard van ValkenburgandGitHub 434ffb8ab8 Python: [BREAKING] Cleanup of dependencies (#1803)
* removed extra and non-released deps

* added comments

* added space and lock

* fix pyright config
2025-10-30 15:24:29 +00:00
KurtandGitHub abfdf75cfb fix: Remove strict thread id validation for tool results (#1769)
* fix: remove strict thread id validation for tool results

* test: remove corresponding test for strict thread_id validation
2025-10-30 14:32:46 +00:00
westeyandGitHub fb7086b2e0 .NET: [BREAKING] Fix issue where AIContextProvider messages were not added to MessageStores (#1788)
* Fix issue where AIContextProvider messages were not added to MessageStores

* Fix typos

* Update XML docs to reduce ambiguity.

* Update AIContext XML docs

* Fix merge issue
2025-10-30 11:55:48 +00:00
westeyandGitHub 36532e929e .NET: Add a Mem0 usage sample (#1779)
* Add a Mem0 usage sample

* Change charset

* Add variable types
2025-10-30 09:31:29 +00:00
Giles OdigweandGitHub 2059e7b3e8 Python: Azure AI Search Support Update + Refactored Samples & Unit Tests (#1683)
* azure ai search sample update

* azure ai search update

* small fix
2025-10-30 02:30:06 +00:00
Tao ChenandGitHub 943d92674e [BREAKING] Python: Replace RequestInfoExecutor with request_info API and @response_handler (#1466)
* Prototype: Add request_info API and @response_handler

* Add original_request as a parameter to the response handler

* Prototype: request interception in sub workflows

* Prototype: request interception in sub workflows 2

* WIP: Make checkpointing work

* checkpointing with sub workflow

* Fix function executor

* Allow sub-workflow to output directly

* Remove ReqeustInfoExecutor and related classes; Debugging checkpoint_with_human_in_the_loop

* Fix Handoff and sample

* fix pending requests in checkpoint

* Fix unit tests

* Fix formatting

* Resolve comments

* Address comment

* Add checkpoint tests

* Add tests

* misc

* fix mypy

* fix mypy

* Use request type as part of the key

* Log warning if there is not response handler for a request

* Update Internal edge group comments

* REcord message type in executor processing span

* Update sample

* Improve tests
2025-10-29 23:31:23 +00:00
westeyandGitHub f6eadd412e Shorten TextSearchSearchResult to TextSearchResult (#1780) 2025-10-29 21:16:28 +00:00
CopilotGitHublokitothcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Jacob Alber
a2ee840eef .NET: Make ChatProtocolExecutor public (#1781)
* Initial plan

* Make ChatProtocolExecutor and ChatProtocolExecutorOptions public with XML documentation

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* Address PR review feedback - remove remarks, fix parameter descriptions

Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>

* ci: Empty Commit to kick over CI

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: lokitoth <6936551+lokitoth@users.noreply.github.com>
Co-authored-by: Jacob Alber <jaalber@microsoft.com>
2025-10-29 19:02:27 +00:00
westeyandGitHub 8a3cab38ee .NET: Add a sample that shows how to do RAG with the TextSearchProvider (#1757)
* Add a sample that shows how to do RAG with the TextSearchProvider

* Update file encoding.
2025-10-29 14:34:00 +00:00
6325873906 .NET: Add deep-research sample (#1752)
* add deep-research sample

* update sample description

* update readme

* remove duplicating section

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

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

* Update dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs

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

* Update dotnet/samples/GettingStarted/Agents/Agent_Step18_DeepResearch/Program.cs

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

* Use CreateAIAgentAsync extension method to create agent

* move deep research agent sample to the catalog folder

---------

Co-authored-by: westey <164392973+westey-m@users.noreply.github.com>
2025-10-29 12:42:21 +00:00
Tao ChenandGitHub 1fbdcf8268 Python: Fix type compatibility check (#1753)
* Fix type compatibility check

* Address comments
2025-10-29 00:48:01 +00:00
ChrisandGitHub 00a78d7bc6 .NET Workflows - Sample and Package Update (#1759)
* Update #1

* Sample comments

* Formatting

* Whitespace
2025-10-28 23:26:55 +00:00
312 changed files with 28837 additions and 9003 deletions
+1
View File
@@ -17,6 +17,7 @@ ignorePatterns:
- pattern: "https://api.powerplatform.com/.default"
- pattern: "https://your-resource.openai.azure.com/"
- pattern: "http://host.docker.internal"
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
# excludedDirs:
# Folders which include links to localhost, since it's not ignored with regular expressions
baseUrl: https://github.com/microsoft/agent-framework/
+26
View File
@@ -16,8 +16,34 @@ env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
paths-filter:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
pythonChanges: ${{ steps.filter.outputs.python}}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
python:
- 'python/**'
# run only if 'python' files were changed
- name: python tests
if: steps.filter.outputs.python == 'true'
run: echo "Python file"
# run only if not 'python' files were changed
- name: not python tests
if: steps.filter.outputs.python != 'true'
run: echo "NOT python file"
python-lab-tests:
name: Python Lab Tests
needs: paths-filter
if: needs.paths-filter.outputs.pythonChanges == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
+2
View File
@@ -60,6 +60,8 @@ jobs:
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL_ID: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
+9 -1
View File
@@ -203,4 +203,12 @@ agents.md
# AI
.claude/
WARP.md
WARP.md
# Frontend
**/frontend/node_modules/
**/frontend/.vite/
**/frontend/dist/
# Database files
*.db
+22
View File
@@ -0,0 +1,22 @@
# Welcome to the Agent Framework Community
Below are some ways that you can get involved in the Agent Framework Community.
## Engage on GitHub
- [Discussions](https://github.com/microsoft/agent-framework/discussions): Ask questions, provide feedback and ideas to what you'd like to see from the Agent Framework.
- [Issues](https://github.com/microsoft/agent-framework/issues) - If you find a bug, unexpected behavior or have a feature request, please open an issue.
- [Pull Requests](https://github.com/microsoft/agent-framework/pulls) - We welcome contributions! Please see our [Contributing Guide](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
We do our best to respond to each submission.
## Public Community Office Hours
We regularly have Community Office Hours that are open to the **public** to join.
Add Agent Framework events to your calendar. We are running two community calls to accommodate different time zones for Q&A Office Hours:
- **Americas & EMEA timezone:** Every Wednesday at 8:00 AM Pacific Time/17:00 CET. Adjusted for daylight savings. Join here: [AF-AG-SK-Americas-Europe-OfficeHours](https://aka.ms/sk-officehours).
- **Asia Pacific timezone:** The second Wednesday of every month at 4:00 PM Pacific Time Wednesday. In much of Asia this occurs on Thursday local time. Adjusted for daylight savings. Join here: [AF-AG-SK-APAC-OfficeHours](https://aka.ms/sk-apac-officehours).
If you are unable to make it live, all meetings will be recorded and posted online.
-1
View File
@@ -22,7 +22,6 @@ This document aims to provide options and capture the decision on how to model t
See various features that would need to be supported via this type of mechanism, plus how various other frameworks support this:
- Also see [dotnet issue 6492](https://github.com/dotnet/extensions/issues/6492), which discusses the need for a similar pattern in the context of MCP approvals.
- Also see [the openai RunToolApprovalItem](https://openai.github.io/openai-agents-js/openai/agents/classes/runtoolapprovalitem/).
- Also see [the openai human-in-the-loop guide](https://openai.github.io/openai-agents-js/guides/human-in-the-loop/#approval-requests).
- Also see [the openai MCP guide](https://openai.github.io/openai-agents-js/guides/mcp/#optional-approval-flow).
- Also see [MCP Approval Requests from OpenAI](https://platform.openai.com/docs/guides/tools-remote-mcp#approvals).
+11 -9
View File
@@ -17,7 +17,7 @@
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="9.8.0" />
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.6" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.7" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.5.0-beta.1" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.4.0" />
@@ -40,18 +40,18 @@
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Exporter.InMemory" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
<!-- Microsoft.AspNetCore.* -->
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="9.0.4" />
<!-- Microsoft.Extensions.* -->
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.10.1" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.1" />
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.10.2" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="9.10.2" />
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="9.10.0-preview.1.25513.3" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.1-preview.1.25521.4" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.10.2-preview.1.25552.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
@@ -65,9 +65,11 @@
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="9.0.10" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
<!-- Vector Stores -->
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.Core" Version="1.66.0" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.OpenAI" Version="1.66.0-preview" />
<PackageVersion Include="Microsoft.SemanticKernel.Agents.AzureAI" Version="1.66.0-preview" />
@@ -83,9 +85,9 @@
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.4.1" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
<PackageVersion Include="OpenAI" Version="2.5.0" />
<PackageVersion Include="OpenAI" Version="2.6.0" />
<!-- Identity -->
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.77.1" />
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.78.0" />
<!-- Workflows -->
<PackageVersion Include="Microsoft.Bot.ObjectModel" Version="1.2025.1003.2" />
<PackageVersion Include="Microsoft.Bot.ObjectModel.Json" Version="1.2025.1003.2" />
+17 -3
View File
@@ -58,16 +58,26 @@
<Project Path="samples/GettingStarted/Agents/Agent_Step15_Plugins/Agent_Step15_Plugins.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step16_ChatReduction/Agent_Step16_ChatReduction.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step17_BackgroundResponses/Agent_Step17_BackgroundResponses.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step18_TextSearchRag/Agent_Step18_TextSearchRag.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step19_Mem0Provider/Agent_Step19_Mem0Provider.csproj" />
<Project Path="samples/GettingStarted/Agents/Agent_Step20_BackgroundResponsesWithToolsAndPersistence/Agent_Step20_BackgroundResponsesWithToolsAndPersistence.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithOpenAI/">
<File Path="samples/GettingStarted/AgentWithOpenAI/README.md" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step01_Running/Agent_OpenAI_Step01_Running.csproj" />
<Project Path="samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Agent_OpenAI_Step02_Reasoning.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/AgentWithRAG/">
<File Path="samples/GettingStarted/AgentWithRAG/README.md" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step01_BasicTextRAG/AgentWithRAG_Step01_BasicTextRAG.csproj" />
<Project Path="samples/GettingStarted/AgentWithRAG/AgentWithRAG_Step02_ExternalDataSourceRAG/AgentWithRAG_Step02_ExternalDataSourceRAG.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/ModelContextProtocol/">
<File Path="samples/GettingStarted/ModelContextProtocol/README.md" />
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/GettingStarted/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/GettingStarted/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
<Project Path="samples/GettingStarted/ModelContextProtocol/ResponseAgent_Hosted_MCP/ResponseAgent_Hosted_MCP.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Observability/">
<Project Path="samples/GettingStarted/AgentOpenTelemetry/AgentOpenTelemetry.csproj" />
@@ -120,6 +130,7 @@
<Folder Name="/Samples/GettingStarted/Workflows/Observability/">
<Project Path="samples/GettingStarted/Workflows/Observability/ApplicationInsights/ApplicationInsights.csproj" />
<Project Path="samples/GettingStarted/Workflows/Observability/AspireDashboard/AspireDashboard.csproj" />
<Project Path="samples/GettingStarted/Workflows/Observability/WorkflowAsAnAgent/WorkflowAsAnAgentObservability.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/Workflows/Visualization/">
<Project Path="samples/GettingStarted/Workflows/Visualization/Visualization.csproj" Id="99bf0bc6-2440-428e-b3e7-d880e4b7a5fd" />
@@ -132,9 +143,12 @@
<Project Path="samples/GettingStarted/Workflows/_Foundational/05_MultiModelService/05_MultiModelService.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/06_SubWorkflows/06_SubWorkflows.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/07_MixedWorkflowAgentsAndExecutors/07_MixedWorkflowAgentsAndExecutors.csproj" />
<Project Path="samples/GettingStarted/Workflows/_Foundational/08_WriterCriticWorkflow/08_WriterCriticWorkflow.csproj" />
</Folder>
<Folder Name="/Samples/SemanticKernelMigration/">
<File Path="samples/SemanticKernelMigration/README.md" />
<Folder Name="/Samples/Catalog/">
<Project Path="samples/Catalog/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/Catalog/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/Catalog/DeepResearchAgent/DeepResearchAgent.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
@@ -294,4 +308,4 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
</Solution>
+3 -3
View File
@@ -2,9 +2,9 @@
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.0.0</VersionPrefix>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251028.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251028.1</PackageVersion>
<GitTag>1.0.0-preview.251028.1</GitTag>
<PackageVersion Condition="'$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).251104.1</PackageVersion>
<PackageVersion Condition="'$(VersionSuffix)' == ''">$(VersionPrefix)-preview.251104.1</PackageVersion>
<GitTag>1.0.0-preview.251104.1</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,82 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
// capabilities to an AI agent. The provider runs a search against an external knowledge base
// before each model invocation and injects the results into the model context.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Data;
using Microsoft.Extensions.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
TextSearchProviderOptions textSearchOptions = new()
{
// Run the search prior to every model invocation and keep a short rolling window of conversation context.
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = _ => new TextSearchProvider(MockSearchAsync, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
Console.WriteLine(">> Asking about returns\n");
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
Console.WriteLine("\n>> Asking about shipping\n");
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
Console.WriteLine("\n>> Asking about product care\n");
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
{
// The mock search inspects the user's question and returns pre-defined snippets
// that resemble documents stored in an external knowledge source.
List<TextSearchProvider.TextSearchResult> results = new();
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "Contoso Outdoors Return Policy",
SourceLink = "https://contoso.com/policies/returns",
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
});
}
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "Contoso Outdoors Shipping Guide",
SourceLink = "https://contoso.com/help/shipping",
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
});
}
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "TrailRunner Tent Care Instructions",
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
});
}
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
@@ -0,0 +1,41 @@
# What this sample demonstrates
This sample demonstrates how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent. The provider runs a search against an external knowledge base before each model invocation and injects the results into the model context.
Key features:
- Configuring TextSearchProvider with custom search behavior
- Running searches before AI invocations to provide relevant context
- Managing conversation memory with a rolling window approach
- Citing source documents in AI responses
## Prerequisites
Before running this sample, ensure you have:
1. An Azure OpenAI endpoint configured
2. A deployment of a chat model (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated
## Environment Variables
Set the following environment variables:
```powershell
# Replace with your Azure OpenAI endpoint
$env:AZURE_OPENAI_ENDPOINT="https://your-openai-resource.openai.azure.com/"
# Optional, defaults to gpt-4o-mini
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini"
```
## How It Works
The sample uses a mock search function that demonstrates the RAG pattern:
1. When the user asks a question, the TextSearchProvider intercepts it
2. The search function looks for relevant documents based on the query
3. Retrieved documents are injected into the model's context
4. The AI responds using both its training and the provided context
5. The agent can cite specific source documents in its answers
The mock search function returns pre-defined snippets for demonstration purposes. In a production scenario, you would replace this with actual searches against your knowledge base (e.g., Azure AI Search, vector database, etc.).
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,48 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to integrate AI agents into a workflow pipeline.
// Three translation agents are connected sequentially to create a translation chain:
// English → French → Spanish → English, showing how agents can be composed as workflow executors.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
// Set up the Azure OpenAI client
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
.GetChatClient(deploymentName)
.AsIChatClient();
// Create agents
AIAgent frenchAgent = GetTranslationAgent("French", chatClient);
AIAgent spanishAgent = GetTranslationAgent("Spanish", chatClient);
AIAgent englishAgent = GetTranslationAgent("English", chatClient);
// Build the workflow by adding executors and connecting them
Workflow workflow = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build();
// Execute the workflow
await using StreamingRun run = await InProcessExecution.StreamAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!"));
// Must send the turn token to trigger the agents.
// The agents are wrapped as executors. When they receive messages,
// they will cache the messages and only start processing when they receive a TurnToken.
await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
if (evt is AgentRunUpdateEvent executorComplete)
{
Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}");
}
}
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
@@ -0,0 +1,26 @@
# What this sample demonstrates
This sample demonstrates the use of AI agents as executors within a workflow.
This workflow uses three translation agents:
1. French Agent - translates input text to French
2. Spanish Agent - translates French text to Spanish
3. English Agent - translates Spanish text back to English
The agents are connected sequentially, creating a translation chain that demonstrates how AI-powered components can be seamlessly integrated into workflow pipelines.
## Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Agents.Persistent" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool.
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
var deepResearchDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME") ?? "o3-deep-research";
var modelDeploymentName = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4o";
var bingConnectionId = Environment.GetEnvironmentVariable("BING_CONNECTION_ID") ?? throw new InvalidOperationException("BING_CONNECTION_ID is not set.");
// Configure extended network timeout for long-running Deep Research tasks.
PersistentAgentsAdministrationClientOptions persistentAgentsClientOptions = new();
persistentAgentsClientOptions.Retry.NetworkTimeout = TimeSpan.FromMinutes(20);
// Get a client to create/retrieve server side agents with.
PersistentAgentsClient persistentAgentsClient = new(endpoint, new AzureCliCredential(), persistentAgentsClientOptions);
// Define and configure the Deep Research tool.
DeepResearchToolDefinition deepResearchTool = new(new DeepResearchDetails(
bingGroundingConnections: [new(bingConnectionId)],
model: deepResearchDeploymentName)
);
// Create an agent with the Deep Research tool on the Azure AI agent service.
AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync(
model: modelDeploymentName,
name: "DeepResearchAgent",
instructions: "You are a helpful Agent that assists in researching scientific topics.",
tools: [deepResearchTool]);
const string Task = "Research the current state of studies on orca intelligence and orca language, " +
"including what is currently known about orcas' cognitive capabilities and communication systems.";
Console.WriteLine($"# User: '{Task}'");
Console.WriteLine();
try
{
AgentThread thread = agent.GetNewThread();
await foreach (var response in agent.RunStreamingAsync(Task, thread))
{
Console.Write(response.Text);
}
}
finally
{
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
}
@@ -0,0 +1,47 @@
# What this sample demonstrates
This sample demonstrates how to create an Azure AI Agent with the Deep Research Tool, which leverages the o3-deep-research reasoning model to perform comprehensive research on complex topics.
Key features:
- Configuring and using the Deep Research Tool with Bing grounding
- Creating a persistent AI agent with deep research capabilities
- Executing deep research queries and retrieving results
## Prerequisites
Before running this sample, ensure you have:
1. An Azure AI Foundry project set up
2. A deep research model deployment (e.g., o3-deep-research)
3. A model deployment (e.g., gpt-4o)
4. A Bing Connection configured in your Azure AI Foundry project
5. Azure CLI installed and authenticated
**Important**: Please visit the following documentation for detailed setup instructions:
- [Deep Research Tool Documentation](https://aka.ms/agents-deep-research)
- [Research Tool Setup](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/how-to/tools/deep-research#research-tool-setup)
Pay special attention to the purple `Note` boxes in the Azure documentation.
**Note**: The Bing Connection ID must be from the **project**, not the resource. It has the following format:
```
/subscriptions/<sub_id>/resourceGroups/<rg_name>/providers/<provider_name>/accounts/<account_name>/projects/<project_name>/connections/<connection_name>
```
## Environment Variables
Set the following environment variables:
```powershell
# Replace with your Azure AI Foundry project endpoint
$env:AZURE_FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/"
# Replace with your Bing connection ID from the project
$env:BING_CONNECTION_ID="/subscriptions/.../connections/your-bing-connection"
# Optional, defaults to o3-deep-research
$env:AZURE_FOUNDRY_PROJECT_DEEP_RESEARCH_DEPLOYMENT_NAME="o3-deep-research"
# Optional, defaults to gpt-4o
$env:AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME="gpt-4o"
@@ -23,7 +23,7 @@ A2ACardResolver agentCardResolver = new(new Uri(a2aAgentHost));
AgentCard agentCard = await agentCardResolver.GetAgentCardAsync();
// Create an instance of the AIAgent for an existing A2A agent specified by the agent card.
AIAgent a2aAgent = await agentCard.GetAIAgentAsync();
AIAgent a2aAgent = agentCard.GetAIAgent();
// Create the main agent, and provide the a2a agent skills as a function tools.
AIAgent agent = new AzureOpenAIClient(
@@ -125,7 +125,7 @@ var agent = new ChatClientAgent(instrumentedChatClient,
instructions: "You are a helpful assistant that provides concise and informative responses.",
tools: [AIFunctionFactory.Create(GetWeatherAsync)])
.AsBuilder()
.UseOpenTelemetry(SourceName) // enable telemetry at the agent level
.UseOpenTelemetry(SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
.Build();
var thread = agent.GetNewThread();
@@ -134,6 +134,8 @@ appLogger.LogInformation("Agent created successfully with ID: {AgentId}", agent.
// Create a parent span for the entire agent session
using var sessionActivity = activitySource.StartActivity("Agent Session");
Console.WriteLine($"Trace ID: {sessionActivity?.TraceId} ");
var sessionId = Guid.NewGuid().ToString("N");
sessionActivity?
.SetTag("agent.name", "OpenTelemetryDemoAgent")
@@ -147,7 +149,7 @@ using (appLogger.BeginScope(new Dictionary<string, object> { ["SessionId"] = ses
while (true)
{
Console.Write("You: ");
Console.Write("You (or 'exit' to quit): ");
var userInput = Console.ReadLine();
if (string.IsNullOrWhiteSpace(userInput) || userInput.Equals("exit", StringComparison.OrdinalIgnoreCase))
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,107 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG) capabilities to an AI agent.
// The sample uses an In-Memory vector store, which can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
// The TextSearchProvider runs a search against the vector store via the TextSearchStore before each model invocation and injects the results into the model context.
// The TextSearchStore is a sample store implementation that hardcodes a storage schema and uses the vector store to store and retrieve documents.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Data;
using Microsoft.Agents.AI.Samples;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
AzureOpenAIClient azureOpenAIClient = new(
new Uri(endpoint),
new AzureCliCredential());
// Create an In-Memory vector store that uses the Azure OpenAI embedding model to generate embeddings.
VectorStore vectorStore = new InMemoryVectorStore(new()
{
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
});
// Create a store that defines a storage schema, and uses the vector store to store and retrieve documents.
TextSearchStore textSearchStore = new(vectorStore, "product-and-policy-info", 3072);
// Upload sample documents into the store.
await textSearchStore.UpsertDocumentsAsync(GetSampleDocuments());
// Create an adapter function that the TextSearchProvider can use to run searches against the TextSearchStore.
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
{
// Here we are limiting the search results to the single top result to demonstrate that we are accurately matching
// specific search results for each question, but in a real world case, more results should be used.
var searchResults = await textSearchStore.SearchAsync(text, 1, ct);
return searchResults.Select(r => new TextSearchProvider.TextSearchResult
{
SourceName = r.SourceName,
SourceLink = r.SourceLink,
Text = r.Text ?? string.Empty,
RawRepresentation = r
});
};
// Configure the options for the TextSearchProvider.
TextSearchProviderOptions textSearchOptions = new()
{
// Run the search prior to every model invocation.
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
};
// Create the AI agent with the TextSearchProvider as the AI context provider.
AIAgent agent = azureOpenAIClient
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
Console.WriteLine(">> Asking about returns\n");
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
Console.WriteLine("\n>> Asking about shipping\n");
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
Console.WriteLine("\n>> Asking about product care\n");
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
// Produces some sample search documents.
// Each one contains a source name and link, which the agent can use to cite sources in its responses.
static IEnumerable<TextSearchDocument> GetSampleDocuments()
{
yield return new TextSearchDocument
{
SourceId = "return-policy-001",
SourceName = "Contoso Outdoors Return Policy",
SourceLink = "https://contoso.com/policies/returns",
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
};
yield return new TextSearchDocument
{
SourceId = "shipping-guide-001",
SourceName = "Contoso Outdoors Shipping Guide",
SourceLink = "https://contoso.com/help/shipping",
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
};
yield return new TextSearchDocument
{
SourceId = "tent-care-001",
SourceName = "TrailRunner Tent Care Instructions",
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
};
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Samples;
/// <summary>
/// Represents a document that can be used for Retrieval Augmented Generation (RAG) that stores textual data.
/// </summary>
public sealed class TextSearchDocument
{
/// <summary>
/// Gets or sets an optional list of namespaces that the document should belong to.
/// </summary>
/// <remarks>
/// A namespace is a logical grouping of documents, e.g. may include a group id to scope the document to a specific group of users.
/// </remarks>
public IList<string> Namespaces { get; set; } = [];
/// <summary>
/// Gets or sets the content as text.
/// </summary>
public string? Text { get; set; }
/// <summary>
/// Gets or sets an optional source ID for the document.
/// </summary>
/// <remarks>
/// This ID should be unique within the collection that the document is stored in, and can
/// be used to map back to the source artifact for this document.
/// If updates need to be made later or the source document was deleted and this document
/// also needs to be deleted, this id can be used to find the document again.
/// </remarks>
public string? SourceId { get; set; }
/// <summary>
/// Gets or sets an optional name for the source document.
/// </summary>
/// <remarks>
/// This can be used to provide display names for citation links when the document is referenced as
/// part of a response to a query.
/// </remarks>
public string? SourceName { get; set; }
/// <summary>
/// Gets or sets an optional link back to the source of the document.
/// </summary>
/// <remarks>
/// This can be used to provide citation links when the document is referenced as
/// part of a response to a query.
/// </remarks>
public string? SourceLink { get; set; }
}
@@ -0,0 +1,392 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq.Expressions;
using System.Text.RegularExpressions;
using Microsoft.Extensions.VectorData;
namespace Microsoft.Agents.AI.Samples;
/// <summary>
/// A class that allows for easy storage and retrieval of documents in a Vector Store for Retrieval Augmented Generation (RAG).
/// </summary>
/// <remarks>
/// <para>
/// This class provides an opinionated schema for storing documents in a vector store. It is valuable for simple scenarios
/// where you want to store text + embedding, or a reference to an external document + embedding without needing to customize the schema.
/// If you want to control the schema yourself, use an implementation of <see cref="VectorStoreCollection{TKey, TRecord}"/> directly instead.
/// </para>
/// <para>
/// This class and its related types are currently provided as a sample implementation, but may be promoted to a first-class supported API in future releases.
/// </para>
/// </remarks>
public sealed partial class TextSearchStore : IDisposable
{
#if NET
[GeneratedRegex(@"\p{L}+", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex AnyLanguageWordRegex();
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text => AnyLanguageWordRegex().Matches(text).Select(x => x.Value).ToList();
#else
private static readonly Regex s_anyLanguageWordRegex = new(@"\p{L}+", RegexOptions.Compiled);
private static Regex AnyLanguageWordRegex() => s_anyLanguageWordRegex;
private static readonly Func<string, ICollection<string>> s_defaultWordSegmenter = text =>
{
List<string> words = new();
foreach (Match word in AnyLanguageWordRegex().Matches(text))
{
words.Add(word.Value);
}
return words;
};
#endif
private readonly VectorStore _vectorStore;
private readonly TextSearchStoreOptions _options;
private readonly Func<string, ICollection<string>> _wordSegmenter;
private readonly VectorStoreCollection<object, Dictionary<string, object?>> _vectorStoreRecordCollection;
private readonly SemaphoreSlim _collectionInitializationLock = new(1, 1);
private bool _collectionInitialized;
private bool _disposedValue;
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchStore"/> class.
/// </summary>
/// <param name="vectorStore">The vector store to store and read the memories from.</param>
/// <param name="collectionName">The name of the collection in the vector store to store and read the memories from.</param>
/// <param name="vectorDimensions">The number of dimensions to use for the memory embeddings.</param>
/// <param name="options">Options to configure the behavior of this class.</param>
/// <exception cref="NotSupportedException">Thrown if the key type provided is not supported.</exception>
public TextSearchStore(
VectorStore vectorStore,
string collectionName,
int vectorDimensions,
TextSearchStoreOptions? options = default)
{
// Verify
if (vectorStore is null)
{
throw new ArgumentNullException(nameof(vectorStore));
}
if (string.IsNullOrWhiteSpace(collectionName))
{
throw new ArgumentException("Collection name cannot be null or whitespace.", nameof(collectionName));
}
if (vectorDimensions < 1)
{
throw new ArgumentOutOfRangeException(nameof(vectorDimensions), "Vector dimensions must be greater than zero.");
}
if (options?.KeyType is not null && options.KeyType != typeof(string) && options.KeyType != typeof(Guid))
{
throw new NotSupportedException($"Unsupported key of type '{options.KeyType.Name}'");
}
if (options?.KeyType is not null && options.KeyType != typeof(string) && options?.UseSourceIdAsPrimaryKey is true)
{
throw new NotSupportedException($"The {nameof(TextSearchStoreOptions.UseSourceIdAsPrimaryKey)} option can only be used when the key type is 'string'.");
}
// Assign
this._vectorStore = vectorStore;
this._options = options ?? new TextSearchStoreOptions();
this._wordSegmenter = this._options.WordSegmenter ?? s_defaultWordSegmenter;
// Create a definition so that we can use the dimensions provided at runtime.
VectorStoreCollectionDefinition ragDocumentDefinition = new()
{
Properties = new List<VectorStoreProperty>()
{
new VectorStoreKeyProperty("Key", this._options.KeyType ?? typeof(string)),
new VectorStoreDataProperty("Namespaces", typeof(List<string>)) { IsIndexed = true },
new VectorStoreDataProperty("SourceId", typeof(string)) { IsIndexed = true },
new VectorStoreDataProperty("Text", typeof(string)) { IsFullTextIndexed = true },
new VectorStoreDataProperty("SourceName", typeof(string)),
new VectorStoreDataProperty("SourceLink", typeof(string)),
new VectorStoreVectorProperty("TextEmbedding", typeof(string), vectorDimensions),
}
};
this._vectorStoreRecordCollection = this._vectorStore.GetDynamicCollection(collectionName, ragDocumentDefinition);
}
/// <summary>
/// Upserts a batch of text chunks into the vector store.
/// </summary>
/// <param name="textChunks">The text chunks to upload.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the documents have been upserted.</returns>
public async Task UpsertTextAsync(IEnumerable<string> textChunks, CancellationToken cancellationToken = default)
{
if (textChunks == null)
{
throw new ArgumentNullException(nameof(textChunks));
}
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
var storageDocuments = textChunks.Select(textChunk =>
{
// Without text we cannot generate a vector.
if (string.IsNullOrWhiteSpace(textChunk))
{
throw new ArgumentException("One of the provided text chunks is null.", nameof(textChunks));
}
return new Dictionary<string, object?>
{
{ "Key", this.GenerateUniqueKey(null) },
{ "Namespaces", new List<string>() },
{ "Text", textChunk },
{ "TextEmbedding", textChunk },
};
});
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Upserts a batch of documents into the vector store.
/// </summary>
/// <param name="documents">The documents to upload.</param>
/// <param name="options">Optional options to control the upsert behavior.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that completes when the documents have been upserted.</returns>
public async Task UpsertDocumentsAsync(IEnumerable<TextSearchDocument> documents, TextSearchStoreUpsertOptions? options = null, CancellationToken cancellationToken = default)
{
if (documents is null)
{
throw new ArgumentNullException(nameof(documents));
}
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
var storageDocuments = documents.Select(document =>
{
if (document is null)
{
throw new ArgumentNullException(nameof(documents), "One of the provided documents is null.");
}
// Without text we cannot generate a vector.
if (string.IsNullOrWhiteSpace(document.Text))
{
throw new ArgumentException($"The {nameof(TextSearchDocument.Text)} property must be set.", nameof(document));
}
// If we aren't persisting the text, we need a source id or link to refer back to the original document.
if (options?.DoNotPersistSourceText is true && string.IsNullOrWhiteSpace(document.SourceId) && string.IsNullOrWhiteSpace(document.SourceLink))
{
throw new ArgumentException($"Either the {nameof(TextSearchDocument.SourceId)} or {nameof(TextSearchDocument.SourceLink)} properties must be set when the {nameof(TextSearchStoreUpsertOptions.DoNotPersistSourceText)} setting is true.", nameof(document));
}
var key = this.GenerateUniqueKey(this._options.UseSourceIdAsPrimaryKey ?? false ? document.SourceId : null);
return new Dictionary<string, object?>()
{
{ "Key", key },
{ "Namespaces", document.Namespaces.ToList() },
{ "SourceId", document.SourceId },
{ "Text", options?.DoNotPersistSourceText is true ? null : document.Text },
{ "SourceName", document.SourceName },
{ "SourceLink", document.SourceLink },
{ "TextEmbedding", document.Text },
};
});
await vectorStoreRecordCollection.UpsertAsync(storageDocuments, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Search the database for documents similar to the provided query.
/// </summary>
/// <param name="query">The text query to find similar documents to.</param>
/// <param name="top">The maximum number of results to return.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The search results.</returns>
public async Task<IEnumerable<TextSearchDocument>> SearchAsync(string query, int top, CancellationToken cancellationToken = default)
{
var searchResult = await this.SearchCoreAsync(query, top, cancellationToken).ConfigureAwait(false);
return searchResult.Select(x => new TextSearchDocument()
{
Namespaces = (List<string>)x["Namespaces"]!,
Text = (string?)x["Text"],
SourceId = (string?)x["SourceId"],
SourceName = (string?)x["SourceName"],
SourceLink = (string?)x["SourceLink"],
});
}
/// <summary>
/// Internal search implementation with hydration of id / link only storage.
/// </summary>
/// <param name="query">The text query to find similar documents to.</param>
/// <param name="top">The maximum number of results to return.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The search results.</returns>
private async Task<IEnumerable<Dictionary<string, object?>>> SearchCoreAsync(string query, int top, CancellationToken cancellationToken = default)
{
// Short circuit if the query is empty.
if (string.IsNullOrWhiteSpace(query))
{
return [];
}
var vectorStoreRecordCollection = await this.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
// If the user has not opted out of hybrid search, check if the vector store supports it.
var hybridSearchCollection = this._options.UseHybridSearch ?? true ?
vectorStoreRecordCollection.GetService(typeof(IKeywordHybridSearchable<Dictionary<string, object?>>)) as IKeywordHybridSearchable<Dictionary<string, object?>> :
null;
// Optional filter to limit the search to a specific namespace.
Expression<Func<Dictionary<string, object?>, bool>>? filter = string.IsNullOrWhiteSpace(this._options.SearchNamespace) ? null : x => ((List<string>)x["Namespaces"]!).Contains(this._options.SearchNamespace);
// Execute a hybrid search if possible, otherwise perform a regular vector search.
var searchResult = hybridSearchCollection is null
? vectorStoreRecordCollection.SearchAsync(
query,
top,
options: new()
{
Filter = filter,
},
cancellationToken: cancellationToken)
: hybridSearchCollection.HybridSearchAsync(
query,
this._wordSegmenter(query),
top,
options: new()
{
Filter = filter,
},
cancellationToken: cancellationToken);
// Retrieve the documents from the search results.
List<Dictionary<string, object?>> searchResponseDocs = new();
await foreach (var searchResponseDoc in searchResult.WithCancellation(cancellationToken).ConfigureAwait(false))
{
searchResponseDocs.Add(searchResponseDoc.Record);
}
// Find any source ids and links for which the text needs to be retrieved.
var sourceIdsToRetrieve = searchResponseDocs
.Where(x => string.IsNullOrWhiteSpace((string?)x["Text"]))
.Select(x => new TextSearchStoreOptions.SourceRetrievalRequest((string?)x["SourceId"], (string?)x["SourceLink"]))
.ToList();
// If we have none, we can return early.
if (sourceIdsToRetrieve.Count == 0)
{
return searchResponseDocs;
}
if (this._options.SourceRetrievalCallback is null)
{
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} option must be set if retrieving documents without stored text.");
}
// Retrieve the source text for the documents that need it.
var retrievalResponses = await this._options.SourceRetrievalCallback(sourceIdsToRetrieve).ConfigureAwait(false);
if (retrievalResponses is null)
{
throw new InvalidOperationException($"The {nameof(TextSearchStoreOptions.SourceRetrievalCallback)} must return a non-null value.");
}
// Update the retrieved documents with the retrieved text.
return searchResponseDocs.GroupJoin(
retrievalResponses,
searchResponseDoc => (searchResponseDoc["SourceId"], searchResponseDoc["SourceLink"]),
retrievalResponse => (retrievalResponse.SourceId, retrievalResponse.SourceLink),
(searchResponseDoc, textRetrievalResponse) => (searchResponseDoc, textRetrievalResponse))
.SelectMany(
joinedSet => joinedSet.textRetrievalResponse.DefaultIfEmpty(),
(combined, textRetrievalResponse) =>
{
combined.searchResponseDoc["Text"] = textRetrievalResponse?.Text ?? combined.searchResponseDoc["Text"];
return combined.searchResponseDoc;
});
}
/// <summary>
/// Thread safe method to get the collection and ensure that it is created at least once.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The created collection.</returns>
private async Task<VectorStoreCollection<object, Dictionary<string, object?>>> EnsureCollectionExistsAsync(CancellationToken cancellationToken)
{
// Return immediately if the collection is already created, no need to do any locking in this case.
if (this._collectionInitialized)
{
return this._vectorStoreRecordCollection;
}
// Wait on a lock to ensure that only one thread can create the collection.
await this._collectionInitializationLock.WaitAsync(cancellationToken).ConfigureAwait(false);
// If multiple threads waited on the lock, and the first already created the collection,
// we can return immediately without doing any work in subsequent threads.
if (this._collectionInitialized)
{
this._collectionInitializationLock.Release();
return this._vectorStoreRecordCollection;
}
// Only the winning thread should reach this point and create the collection.
try
{
await this._vectorStoreRecordCollection.EnsureCollectionExistsAsync(cancellationToken).ConfigureAwait(false);
this._collectionInitialized = true;
}
finally
{
this._collectionInitializationLock.Release();
}
return this._vectorStoreRecordCollection;
}
/// <summary>
/// Generates a unique key for the RAG document.
/// </summary>
/// <param name="sourceId">Source id of the source document for this RAG document.</param>
/// <returns>A new unique key.</returns>
/// <exception cref="NotSupportedException">Thrown if the requested key type is not supported.</exception>
private object GenerateUniqueKey(string? sourceId)
=> this._options.KeyType switch
{
_ when (this._options.KeyType == null || this._options.KeyType == typeof(string)) && !string.IsNullOrWhiteSpace(sourceId) => sourceId!,
_ when this._options.KeyType == null || this._options.KeyType == typeof(string) => Guid.NewGuid().ToString(),
_ when this._options.KeyType == typeof(Guid) => Guid.NewGuid(),
_ => throw new NotSupportedException($"Unsupported key of type '{this._options.KeyType.Name}'")
};
/// <inheritdoc/>
private void Dispose(bool disposing)
{
if (!this._disposedValue)
{
if (disposing)
{
this._vectorStoreRecordCollection.Dispose();
this._collectionInitializationLock.Dispose();
}
this._disposedValue = true;
}
}
/// <inheritdoc/>
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
this.Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Samples;
/// <summary>
/// Contains options for the <see cref="TextSearchStore"/>.
/// </summary>
public sealed class TextSearchStoreOptions
{
/// <summary>
/// Gets or sets an optional namespace to pre-filter the possible
/// records with when doing a vector search.
/// </summary>
public string? SearchNamespace { get; init; }
/// <summary>
/// Gets or sets a value indicating whether to use the source ID as the primary key for records.
/// </summary>
/// <remarks>
/// <para>
/// Using the source ID as the primary key allows for easy updates from the source for any changed
/// records, since those records can just be upserted again, and will overwrite the previous version
/// of the same record.
/// </para>
/// <para>
/// This setting can only be used when the chosen key type is a string.
/// </para>
/// </remarks>
/// <value>
/// Defaults to <c>false</c> if not set.
/// </value>
public bool? UseSourceIdAsPrimaryKey { get; init; }
/// <summary>
/// Gets or sets a value indicating whether to use hybrid search if it is available for the provided vector store.
/// </summary>
/// <value>
/// Defaults to <c>true</c> if not set.
/// </value>
public bool? UseHybridSearch { get; init; }
/// <summary>
/// Gets or sets a word segmenter function to split search text into separate words for the purposes of hybrid search.
/// This will not be used if <see cref="UseHybridSearch"/> is set to <c>false</c>.
/// </summary>
/// <remarks>
/// Defaults to a simple text-character-based segmenter that splits the text by any character that is not a text character.
/// </remarks>
public Func<string, ICollection<string>>? WordSegmenter { get; init; }
/// <summary>
/// Gets or sets the type of key to use for records in the text search store.
/// </summary>
/// <remarks>
/// Make sure to pick a key type that is supported by the underlying vector store.
/// Note that you have to choose <see cref="string"/> when using <see cref="UseSourceIdAsPrimaryKey"/>.
/// </remarks>
/// <value>Defaults to <see cref="string"/> if not set. Only <see cref="string"/> and <see cref="Guid"/> is currently supported.</value>
public Type? KeyType { get; init; }
/// <summary>
/// Gets or sets an optional callback to load the source text using the source id or source link
/// if the source text is not persisted in the database.
/// </summary>
/// <remarks>
/// The response should include the source id or source link, as provided in the request,
/// plus the source text loaded from the source.
/// </remarks>
public Func<List<SourceRetrievalRequest>, Task<IEnumerable<SourceRetrievalResponse>>>? SourceRetrievalCallback { get; init; }
/// <summary>
/// Represents a request to the <see cref="SourceRetrievalCallback"/>.
/// </summary>
public sealed class SourceRetrievalRequest
{
/// <summary>
/// Initializes a new instance of the <see cref="SourceRetrievalRequest"/> class.
/// </summary>
/// <param name="sourceId">The source ID of the document to retrieve.</param>
/// <param name="sourceLink">The source link of the document to retrieve.</param>
public SourceRetrievalRequest(string? sourceId, string? sourceLink)
{
this.SourceId = sourceId;
this.SourceLink = sourceLink;
}
/// <summary>
/// Gets or sets the source ID of the document to retrieve.
/// </summary>
public string? SourceId { get; set; }
/// <summary>
/// Gets or sets the source link of the document to retrieve.
/// </summary>
public string? SourceLink { get; set; }
}
/// <summary>
/// Represents a response from the <see cref="SourceRetrievalCallback"/>.
/// </summary>
public sealed class SourceRetrievalResponse
{
/// <summary>
/// Initializes a new instance of the <see cref="SourceRetrievalResponse"/> class.
/// </summary>
/// <param name="request">The request matching this response.</param>
/// <param name="text">The source text that was retrieved.</param>
public SourceRetrievalResponse(SourceRetrievalRequest request, string text)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
if (text == null)
{
throw new ArgumentNullException(nameof(text));
}
this.SourceId = request.SourceId;
this.SourceLink = request.SourceLink;
this.Text = text;
}
/// <summary>
/// Gets or sets the source ID of the document that was retrieved.
/// </summary>
public string? SourceId { get; set; }
/// <summary>
/// Gets or sets the source link of the document that was retrieved.
/// </summary>
public string? SourceLink { get; set; }
/// <summary>
/// Gets or sets the source text of the document that was retrieved.
/// </summary>
public string Text { get; set; }
}
}
@@ -0,0 +1,17 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Samples;
/// <summary>
/// Contains options for <see cref="TextSearchStore.UpsertDocumentsAsync(IEnumerable{TextSearchDocument}, TextSearchStoreUpsertOptions?, CancellationToken)"/>.
/// </summary>
public sealed class TextSearchStoreUpsertOptions
{
/// <summary>
/// Gets or sets a value indicating whether the source text should be persisted in the database.
/// </summary>
/// <value>
/// Defaults to <see langword="false"/> if not set.
/// </value>
public bool DoNotPersistSourceText { get; init; }
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use Qdrant to add retrieval augmented generation (RAG) capabilities to an AI agent.
// While the sample is using Qdrant, it can easily be replaced with any other vector store that implements the Microsoft.Extensions.VectorData abstractions.
// The TextSearchProvider runs a search against the vector store before each model invocation and injects the results into the model context.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Data;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using OpenAI;
using Qdrant.Client;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
var afOverviewUrl = "https://github.com/MicrosoftDocs/semantic-kernel-docs/blob/main/agent-framework/overview/agent-framework-overview.md";
var afMigrationUrl = "https://raw.githubusercontent.com/MicrosoftDocs/semantic-kernel-docs/refs/heads/main/agent-framework/migration-guide/from-semantic-kernel/index.md";
AzureOpenAIClient azureOpenAIClient = new(
new Uri(endpoint),
new AzureCliCredential());
// Create a Qdrant vector store that uses the Azure OpenAI embedding model to generate embeddings.
QdrantClient client = new("localhost");
VectorStore vectorStore = new QdrantVectorStore(client, ownsClient: true, new()
{
EmbeddingGenerator = azureOpenAIClient.GetEmbeddingClient(embeddingDeploymentName).AsIEmbeddingGenerator()
});
// Create a collection and upsert some text into it.
var documentationCollection = vectorStore.GetCollection<Guid, DocumentationChunk>("documentation");
await documentationCollection.EnsureCollectionDeletedAsync(); // Clear out any data from previous runs.
await documentationCollection.EnsureCollectionExistsAsync();
await UploadDataFromMarkdown(afOverviewUrl, "Microsoft Agent Framework Overview", documentationCollection, 2000, 200);
await UploadDataFromMarkdown(afMigrationUrl, "Semantic Kernel to Microsoft Agent Framework Migration Guide", documentationCollection, 2000, 200);
// Create an adapter function that the TextSearchProvider can use to run searches against the collection.
Func<string, CancellationToken, Task<IEnumerable<TextSearchProvider.TextSearchResult>>> SearchAdapter = async (text, ct) =>
{
List<TextSearchProvider.TextSearchResult> results = [];
await foreach (var result in documentationCollection.SearchAsync(text, 5, cancellationToken: ct))
{
results.Add(new TextSearchProvider.TextSearchResult
{
SourceName = result.Record.SourceName,
SourceLink = result.Record.SourceLink,
Text = result.Record.Text ?? string.Empty,
RawRepresentation = result
});
}
return results;
};
// Configure the options for the TextSearchProvider.
TextSearchProviderOptions textSearchOptions = new()
{
// Run the search prior to every model invocation.
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
// Use up to 4 recent messages when searching so that searches
// still produce valuable results even when the user is referring
// back to previous messages in their request.
RecentMessageMemoryLimit = 5
};
// Create the AI agent with the TextSearchProvider as the AI context provider.
AIAgent agent = azureOpenAIClient
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for the Microsoft Agent Framework. Answer questions using the provided context and cite the source document when available. Keep responses brief.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(SearchAdapter, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(SearchAdapter, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
Console.WriteLine(">> Asking about SK threads\n");
Console.WriteLine(await agent.RunAsync("Hi! How do I create a thread in Semantic Kernel?", thread));
// Here we are asking a very vague question when taken out of context,
// but since we are including previous messages in our search using RecentMessageMemoryLimit
// the RAG search should still produce useful results.
Console.WriteLine("\n>> Asking about AF threads\n");
Console.WriteLine(await agent.RunAsync("and in Agent Framework?", thread));
Console.WriteLine("\n>> Contrasting Approaches\n");
Console.WriteLine(await agent.RunAsync("Please contrast the two approaches", thread));
Console.WriteLine("\n>> Asking about ancestry\n");
Console.WriteLine(await agent.RunAsync("What are the predecessors to the Agent Framework?", thread));
static async Task UploadDataFromMarkdown(string markdownUrl, string sourceName, VectorStoreCollection<Guid, DocumentationChunk> vectorStoreCollection, int chunkSize, int overlap)
{
// Download the markdown from the given url.
using HttpClient client = new();
var markdown = await client.GetStringAsync(new Uri(markdownUrl));
// Chunk it into separate parts with some overlap between chunks
var chunks = new List<DocumentationChunk>();
for (int i = 0; i < markdown.Length; i += chunkSize)
{
var chunk = new DocumentationChunk
{
Key = Guid.NewGuid(),
SourceLink = markdownUrl,
SourceName = sourceName,
Text = markdown.Substring(i, Math.Min(chunkSize + overlap, markdown.Length - i))
};
chunks.Add(chunk);
}
// Upsert each chunk into the provided vector store.
await vectorStoreCollection.UpsertAsync(chunks);
}
// Data model that defines the database schema we want to use.
internal sealed class DocumentationChunk
{
[VectorStoreKey]
public Guid Key { get; set; }
[VectorStoreData]
public string SourceLink { get; set; } = string.Empty;
[VectorStoreData]
public string SourceName { get; set; } = string.Empty;
[VectorStoreData]
public string Text { get; set; } = string.Empty;
[VectorStoreVector(Dimensions: 3072)]
public string Embedding => this.Text;
}
@@ -0,0 +1,60 @@
# Agent Framework Retrieval Augmented Generation (RAG) with an external Vector Store with a custom schema
This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store.
It also uses a custom schema for the documents stored in the vector store.
This sample uses Qdrant for the vector store, but this can easily be swapped out for any vector store that has a Microsoft.Extensions.VectorStore implementation.
## Prerequisites
- .NET 8.0 SDK or later
- Azure OpenAI service endpoint
- Both a chat completion and embedding deployment configured in the Azure OpenAI resource
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
- An existing Qdrant instance. You can use a managed service or run a local instance using Docker, but the sample assumes the instance is running locally.
**Note**: These samples use Azure OpenAI models. For more information, see [how to deploy Azure OpenAI models with Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/how-to/deploy-models-openai).
**Note**: These samples use Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource and have the `Cognitive Services OpenAI Contributor` role. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
## Running the sample from the console
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
$env:AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME="text-embedding-3-large" # Optional, defaults to text-embedding-3-large
```
If the variables are not set, you will be prompted for the values when running the samples.
To use Qdrant in docker locally, start your Qdrant instance using the default port mappings.
```powershell
docker run -d --name qdrant -p 6333:6333 -p 6334:6334 qdrant/qdrant:latest
```
Execute the following command to build the sample:
```powershell
dotnet build
```
Execute the following command to run the sample:
```powershell
dotnet run --no-build
```
Or just build and run in one step:
```powershell
dotnet run
```
## Running the sample from Visual Studio
Open the solution in Visual Studio and set the sample project as the startup project. Then, run the project using the built-in debugger or by pressing `F5`.
You will be prompted for any required environment variables if they are not already set.
@@ -0,0 +1,8 @@
# Agent Framework Retrieval Augmented Generation (RAG)
These samples show how to create an agent with the Agent Framework that uses Retrieval Augmented Generation (RAG) to enhance its responses with information from a knowledge base.
|Sample|Description|
|---|---|
|[Basic Text RAG](./AgentWithRAG_Step01_BasicTextRAG/)|This sample demonstrates how to create and run a basic agent with simple text Retrieval Augmented Generation (RAG).|
|[RAG with external Vector Store and custom schema](./AgentWithRAG_Step02_ExternalDataSourceRAG/)|This sample demonstrates how to create and run an agent that uses Retrieval Augmented Generation (RAG) with an external vector store. It also uses a custom schema for the documents stored in the vector store.|
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use background responses with ChatClientAgent and OpenAI Responses.
// This sample shows how to use background responses with ChatClientAgent and Azure OpenAI Responses.
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -1,6 +1,6 @@
# What This Sample Shows
This sample demonstrates how to use background responses with ChatCompletionAgent and OpenAI Responses for long-running operations. Background responses support:
This sample demonstrates how to use background responses with ChatCompletionAgent and Azure OpenAI Responses for long-running operations. Background responses support:
- **Polling for completion** - Non-streaming APIs can start a background operation and return a continuation token. Poll with the token until the response completes.
- **Resuming after interruption** - Streaming APIs can be interrupted and resumed from the last update using the continuation token.
@@ -14,7 +14,10 @@ For more information, see the [official documentation](https://learn.microsoft.c
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- OpenAI api key
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,84 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use TextSearchProvider to add retrieval augmented generation (RAG)
// capabilities to an AI agent. The provider runs a search against an external knowledge base
// before each model invocation and injects the results into the model context.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Data;
using Microsoft.Extensions.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
TextSearchProviderOptions textSearchOptions = new()
{
// Run the search prior to every model invocation and keep a short rolling window of conversation context.
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 6,
};
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
{
Instructions = "You are a helpful support specialist for Contoso Outdoors. Answer questions using the provided context and cite the source document when available.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not System.Text.Json.JsonValueKind.Null and not System.Text.Json.JsonValueKind.Undefined
? new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
: new TextSearchProvider(MockSearchAsync, textSearchOptions)
});
AgentThread thread = agent.GetNewThread();
Console.WriteLine(">> Asking about returns\n");
Console.WriteLine(await agent.RunAsync("Hi! I need help understanding the return policy.", thread));
Console.WriteLine("\n>> Asking about shipping\n");
Console.WriteLine(await agent.RunAsync("How long does standard shipping usually take?", thread));
Console.WriteLine("\n>> Asking about product care\n");
Console.WriteLine(await agent.RunAsync("What is the best way to maintain the TrailRunner tent fabric?", thread));
static Task<IEnumerable<TextSearchProvider.TextSearchResult>> MockSearchAsync(string query, CancellationToken cancellationToken)
{
// The mock search inspects the user's question and returns pre-defined snippets
// that resemble documents stored in an external knowledge source.
List<TextSearchProvider.TextSearchResult> results = new();
if (query.Contains("return", StringComparison.OrdinalIgnoreCase) || query.Contains("refund", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "Contoso Outdoors Return Policy",
SourceLink = "https://contoso.com/policies/returns",
Text = "Customers may return any item within 30 days of delivery. Items should be unused and include original packaging. Refunds are issued to the original payment method within 5 business days of inspection."
});
}
if (query.Contains("shipping", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "Contoso Outdoors Shipping Guide",
SourceLink = "https://contoso.com/help/shipping",
Text = "Standard shipping is free on orders over $50 and typically arrives in 3-5 business days within the continental United States. Expedited options are available at checkout."
});
}
if (query.Contains("tent", StringComparison.OrdinalIgnoreCase) || query.Contains("fabric", StringComparison.OrdinalIgnoreCase))
{
results.Add(new()
{
SourceName = "TrailRunner Tent Care Instructions",
SourceLink = "https://contoso.com/manuals/trailrunner-tent",
Text = "Clean the tent fabric with lukewarm water and a non-detergent soap. Allow it to air dry completely before storage and avoid prolonged UV exposure to extend the lifespan of the waterproof coating."
});
}
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(results);
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mem0\Microsoft.Agents.AI.Mem0.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to use the Mem0Provider to persist and recall memories for an agent.
// The sample stores conversation messages in a Mem0 service and retrieves relevant memories
// for subsequent invocations, even across new threads.
using System.Net.Http.Headers;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Mem0;
using Microsoft.Extensions.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var mem0ServiceUri = Environment.GetEnvironmentVariable("MEM0_ENDPOINT") ?? throw new InvalidOperationException("MEM0_ENDPOINT is not set.");
var mem0ApiKey = Environment.GetEnvironmentVariable("MEM0_APIKEY") ?? throw new InvalidOperationException("MEM0_APIKEY is not set.");
// Create an HttpClient for Mem0 with the required base address and authentication.
using HttpClient mem0HttpClient = new();
mem0HttpClient.BaseAddress = new Uri(mem0ServiceUri);
mem0HttpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Token", mem0ApiKey);
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions()
{
Instructions = "You are a friendly travel assistant. Use known memories about the user when responding, and do not invent details.",
AIContextProviderFactory = ctx => ctx.SerializedState.ValueKind is not JsonValueKind.Null or JsonValueKind.Undefined
// If each thread should have its own Mem0 scope, you can create a new id per thread here:
// ? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ThreadId = Guid.NewGuid().ToString() })
// In this case we are storing memories scoped by application and user instead so that memories are retained across threads.
? new Mem0Provider(mem0HttpClient, new Mem0ProviderOptions() { ApplicationId = "getting-started-agents", UserId = "sample-user" })
// For cases where we are restoring from serialized state:
: new Mem0Provider(mem0HttpClient, ctx.SerializedState, ctx.JsonSerializerOptions)
});
AgentThread thread = agent.GetNewThread();
// Clear any existing memories for this scope to demonstrate fresh behavior.
Mem0Provider mem0Provider = thread.GetService<Mem0Provider>()!;
await mem0Provider.ClearStoredMemoriesAsync();
Console.WriteLine(await agent.RunAsync("Hi there! My name is Taylor and I'm planning a hiking trip to Patagonia in November.", thread));
Console.WriteLine(await agent.RunAsync("I'm travelling with my sister and we love finding scenic viewpoints.", thread));
Console.WriteLine("\nWaiting briefly for Mem0 to index the new memories...\n");
await Task.Delay(TimeSpan.FromSeconds(2));
Console.WriteLine(await agent.RunAsync("What do you already know about my upcoming trip?", thread));
Console.WriteLine("\n>> Serialize and deserialize the thread to demonstrate persisted state\n");
JsonElement serializedThread = thread.Serialize();
AgentThread restoredThread = agent.DeserializeThread(serializedThread);
Console.WriteLine(await agent.RunAsync("Can you recap the personal details you remember?", restoredThread));
Console.WriteLine("\n>> Start a new thread that shares the same Mem0 scope\n");
AgentThread newThread = agent.GetNewThread();
Console.WriteLine(await agent.RunAsync("Summarize what you already know about me.", newThread));
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,108 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to use background responses with ChatClientAgent and Azure OpenAI Responses for long-running operations.
// It shows polling for completion using continuation tokens, function calling during background operations,
// and persisting/restoring agent state between polling cycles.
#pragma warning disable CA1050 // Declare types in namespaces
using System.ComponentModel;
using System.Text.Json;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5";
var stateStore = new Dictionary<string, JsonElement?>();
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(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." +
"Write complete chapters without asking for approval or feedback. Do not ask the user about tone, style, pace, or format preferences - just write the novel based on the request.",
tools: [AIFunctionFactory.Create(ResearchSpaceFactsAsync), AIFunctionFactory.Create(GenerateCharacterProfilesAsync)]);
// Enable background responses (only supported by {Azure}OpenAI Responses at this time).
AgentRunOptions options = new() { AllowBackgroundResponses = true };
AgentThread thread = agent.GetNewThread();
// Start the initial run.
AgentRunResponse response = await agent.RunAsync("Write a very long novel about a team of astronauts exploring an uncharted galaxy.", thread, options);
// Poll for background responses until complete.
while (response.ContinuationToken is not null)
{
PersistAgentState(thread, response.ContinuationToken);
await Task.Delay(TimeSpan.FromSeconds(10));
RestoreAgentState(agent, out thread, out object? continuationToken);
options.ContinuationToken = continuationToken;
response = await agent.RunAsync(thread, options);
}
Console.WriteLine(response.Text);
void PersistAgentState(AgentThread thread, object? continuationToken)
{
stateStore["thread"] = thread.Serialize();
stateStore["continuationToken"] = JsonSerializer.SerializeToElement(continuationToken, AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
void RestoreAgentState(AIAgent agent, out AgentThread thread, out object? continuationToken)
{
JsonElement serializedThread = stateStore["thread"] ?? throw new InvalidOperationException("No serialized thread found in state store.");
JsonElement? serializedToken = stateStore["continuationToken"];
thread = agent.DeserializeThread(serializedThread);
continuationToken = serializedToken?.Deserialize(AgentAbstractionsJsonUtilities.DefaultOptions.GetTypeInfo(typeof(ResponseContinuationToken)));
}
[Description("Researches relevant space facts and scientific information for writing a science fiction novel")]
async Task<string> ResearchSpaceFactsAsync(string topic)
{
Console.WriteLine($"[ResearchSpaceFacts] Researching topic: {topic}");
// Simulate a research operation
await Task.Delay(TimeSpan.FromSeconds(10));
string result = topic.ToUpperInvariant() switch
{
var t when t.Contains("GALAXY") => "Research findings: Galaxies contain billions of stars. Uncharted galaxies may have unique stellar formations, exotic matter, and unexplored phenomena like dark energy concentrations.",
var t when t.Contains("SPACE") || t.Contains("TRAVEL") => "Research findings: Interstellar travel requires advanced propulsion systems. Challenges include radiation exposure, life support, and navigation through unknown space.",
var t when t.Contains("ASTRONAUT") => "Research findings: Astronauts undergo rigorous training in zero-gravity environments, emergency protocols, spacecraft systems, and team dynamics for long-duration missions.",
_ => $"Research findings: General space exploration facts related to {topic}. Deep space missions require advanced technology, crew resilience, and contingency planning for unknown scenarios."
};
Console.WriteLine("[ResearchSpaceFacts] Research complete");
return result;
}
[Description("Generates character profiles for the main astronaut characters in the novel")]
async Task<IEnumerable<string>> GenerateCharacterProfilesAsync()
{
Console.WriteLine("[GenerateCharacterProfiles] Generating character profiles...");
// Simulate a character generation operation
await Task.Delay(TimeSpan.FromSeconds(10));
string[] profiles = [
"Captain Elena Voss: A seasoned mission commander with 15 years of experience. Strong-willed and decisive, she struggles with the weight of responsibility for her crew. Former military pilot turned astronaut.",
"Dr. James Chen: Chief science officer and astrophysicist. Brilliant but socially awkward, he finds solace in data and discovery. His curiosity often pushes the mission into uncharted territory.",
"Lieutenant Maya Torres: Navigation specialist and youngest crew member. Optimistic and tech-savvy, she brings fresh perspective and innovative problem-solving to challenges.",
"Commander Marcus Rivera: Chief engineer with expertise in spacecraft systems. Pragmatic and resourceful, he can fix almost anything with limited resources. Values crew safety above all.",
"Dr. Amara Okafor: Medical officer and psychologist. Empathetic and observant, she helps maintain crew morale and mental health during the long journey. Expert in space medicine."
];
Console.WriteLine($"[GenerateCharacterProfiles] Generated {profiles.Length} character profiles");
return profiles;
}
@@ -0,0 +1,28 @@
# What This Sample Shows
This sample demonstrates how to use background responses with ChatCompletionAgent and Azure OpenAI Responses for long-running operations. Background responses support:
- **Polling for completion** - Non-streaming APIs can start a background operation and return a continuation token. Poll with the token until the response completes.
- **Function calling** - Functions can be called during background operations.
- **State persistence** - Thread and continuation token can be persisted and restored between polling cycles.
> **Note:** Background responses are currently only supported by OpenAI Responses.
For more information, see the [official documentation](https://learn.microsoft.com/en-us/agent-framework/user-guide/agents/agent-background-responses?pivots=programming-language-csharp).
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5" # Optional, defaults to gpt-5
```
@@ -43,6 +43,9 @@ Before you begin, ensure you have the following prerequisites:
|[Using plugins with an agent](./Agent_Step15_Plugins/)|This sample demonstrates how to use plugins with an agent|
|[Reducing chat history size](./Agent_Step16_ChatReduction/)|This sample demonstrates how to reduce the chat history to constrain its size, where chat history is maintained locally|
|[Background responses](./Agent_Step17_BackgroundResponses/)|This sample demonstrates how to use background responses for long-running operations with polling and resumption support|
|[Adding RAG with text search](./Agent_Step18_TextSearchRag/)|This sample demonstrates how to enrich agent responses with retrieval augmented generation using the text search provider|
|[Using Mem0-backed memory](./Agent_Step19_Mem0Provider/)|This sample demonstrates how to use the Mem0Provider to persist and recall memories across conversations|
|[Background responses with tools and persistence](./Agent_Step20_BackgroundResponsesWithToolsAndPersistence/)|This sample demonstrates advanced background response scenarios including function calling during background operations and state persistence|
## Running the samples from the console
@@ -1,52 +1,106 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend.
// This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend, that uses a Hosted MCP Tool.
// In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
using Azure.AI.Agents.Persistent;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_PROJECT_ENDPOINT is not set.");
var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_MODEL_ID") ?? "gpt-4.1-mini";
var model = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_PROJECT_DEPLOYMENT_NAME") ?? "gpt-4.1-mini";
// Get a client to create/retrieve server side agents with.
var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential());
// **** MCP Tool with Auto Approval ****
// *************************************
// Create an MCP tool definition that the agent can use.
var mcpTool = new MCPToolDefinition(
serverLabel: "microsoft_learn",
serverUrl: "https://learn.microsoft.com/api/mcp");
mcpTool.AllowedTools.Add("microsoft_docs_search");
// Create a server side persistent agent with the Azure.AI.Agents.Persistent SDK.
var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync(
model: model,
name: "MicrosoftLearnAgent",
instructions: "You answer questions by searching the Microsoft Learn content only.",
tools: [mcpTool]);
// Retrieve an already created server side persistent agent as an AIAgent.
AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id);
// Create run options to configure the agent invocation.
var runOptions = new ChatClientAgentRunOptions()
// In this case we allow the tool to always be called without approval.
var mcpTool = new HostedMcpServerTool(
serverName: "microsoft_learn",
serverAddress: "https://learn.microsoft.com/api/mcp")
{
ChatOptions = new()
{
RawRepresentationFactory = (_) => new ThreadAndRunOptions()
{
ToolResources = new MCPToolResource(serverLabel: "microsoft_learn")
{
RequireApproval = new MCPApproval("never"),
}.ToToolResources()
}
}
AllowedTools = ["microsoft_docs_search"],
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
};
// Create a server side persistent agent with the mcp tool, and expose it as an AIAgent.
AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync(
model: model,
options: new()
{
Name = "MicrosoftLearnAgent",
Instructions = "You answer questions by searching the Microsoft Learn content only.",
ChatOptions = new()
{
Tools = [mcpTool]
},
});
// You can then invoke the agent like any other AIAgent.
AgentThread thread = agent.GetNewThread();
var response = await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread, runOptions);
Console.WriteLine(response);
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
// Cleanup for sample purposes.
await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id);
// **** MCP Tool with Approval Required ****
// *****************************************
// Create an MCP tool definition that the agent can use.
// In this case we require approval before the tool can be called.
var mcpToolWithApproval = new HostedMcpServerTool(
serverName: "microsoft_learn",
serverAddress: "https://learn.microsoft.com/api/mcp")
{
AllowedTools = ["microsoft_docs_search"],
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
};
// Create an agent based on Azure OpenAI Responses as the backend.
AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAsync(
model: model,
options: new()
{
Name = "MicrosoftLearnAgentWithApproval",
Instructions = "You answer questions by searching the Microsoft Learn content only.",
ChatOptions = new()
{
Tools = [mcpToolWithApproval]
},
});
// You can then invoke the agent like any other AIAgent.
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
var userInputRequests = response.UserInputRequests.ToList();
while (userInputRequests.Count > 0)
{
// Ask the user to approve each MCP call request.
// For simplicity, we are assuming here that only MCP approval requests are being made.
var userInputResponses = userInputRequests
.OfType<McpServerToolApprovalRequestContent>()
.Select(approvalRequest =>
{
Console.WriteLine($"""
The agent would like to invoke the following MCP Tool, please reply Y to approve.
ServerName: {approvalRequest.ToolCall.ServerName}
Name: {approvalRequest.ToolCall.ToolName}
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
""");
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
})
.ToList();
// Pass the user input responses back to the agent for further processing.
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
userInputRequests = response.UserInputRequests.ToList();
}
Console.WriteLine($"\nAgent: {response}");
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
|---|---|
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
## Running the samples from the console
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create and use a simple AI agent with OpenAI Responses as the backend, that uses a Hosted MCP Tool.
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
// The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
// **** MCP Tool with Auto Approval ****
// *************************************
// Create an MCP tool definition that the agent can use.
// In this case we allow the tool to always be called without approval.
var mcpTool = new HostedMcpServerTool(
serverName: "microsoft_learn",
serverAddress: "https://learn.microsoft.com/api/mcp")
{
AllowedTools = ["microsoft_docs_search"],
ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire
};
// Create an agent based on Azure OpenAI Responses as the backend.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
tools: [mcpTool]);
// You can then invoke the agent like any other AIAgent.
AgentThread thread = agent.GetNewThread();
Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", thread));
// **** MCP Tool with Approval Required ****
// *****************************************
// Create an MCP tool definition that the agent can use.
// In this case we require approval before the tool can be called.
var mcpToolWithApproval = new HostedMcpServerTool(
serverName: "microsoft_learn",
serverAddress: "https://learn.microsoft.com/api/mcp")
{
AllowedTools = ["microsoft_docs_search"],
ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire
};
// Create an agent based on Azure OpenAI Responses as the backend.
AIAgent agentWithRequiredApproval = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
.GetOpenAIResponseClient(deploymentName)
.CreateAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgentWithApproval",
tools: [mcpToolWithApproval]);
// You can then invoke the agent like any other AIAgent.
var threadWithRequiredApproval = agentWithRequiredApproval.GetNewThread();
var response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", threadWithRequiredApproval);
var userInputRequests = response.UserInputRequests.ToList();
while (userInputRequests.Count > 0)
{
// Ask the user to approve each MCP call request.
// For simplicity, we are assuming here that only MCP approval requests are being made.
var userInputResponses = userInputRequests
.OfType<McpServerToolApprovalRequestContent>()
.Select(approvalRequest =>
{
Console.WriteLine($"""
The agent would like to invoke the following MCP Tool, please reply Y to approve.
ServerName: {approvalRequest.ToolCall.ServerName}
Name: {approvalRequest.ToolCall.ToolName}
Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])}
""");
return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]);
})
.ToList();
// Pass the user input responses back to the agent for further processing.
response = await agentWithRequiredApproval.RunAsync(userInputResponses, threadWithRequiredApproval);
userInputRequests = response.UserInputRequests.ToList();
}
Console.WriteLine($"\nAgent: {response}");
@@ -0,0 +1,17 @@
# Prerequisites
Before you begin, ensure you have the following prerequisites:
- .NET 8.0 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource.
**Note**: This demo uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource. For more information, see the [Azure CLI documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
Set the following environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Replace with your Azure OpenAI resource endpoint
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" # Optional, defaults to gpt-4.1-mini
```
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -6,7 +6,7 @@ using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentsSample;
namespace WorkflowAsAnAgentSample;
/// <summary>
/// This sample introduces the concepts workflows as agents, where a workflow can be
@@ -61,9 +61,9 @@ public static class Program
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
{
if (update.MessageId is null)
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
{
// skip updates that don't have a message ID
// skip updates that don't have a message ID or text
continue;
}
Console.Clear();
@@ -4,7 +4,7 @@ using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentsSample;
namespace WorkflowAsAnAgentSample;
internal static class WorkflowFactory
{
@@ -41,44 +41,43 @@ internal static class WorkflowFactory
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed class ConcurrentStartExecutor() :
Executor<List<ChatMessage>>("ConcurrentStartExecutor")
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
{
/// <summary>
/// Starts the concurrent processing by sending messages to the agents.
/// </summary>
/// <param name="message">The user message to process</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
// Broadcast the message to all connected agents. Receiving agents will queue
// the message but will not start processing until they receive a turn token.
await context.SendMessageAsync(message, cancellationToken: cancellationToken);
// Broadcast the turn token to kick off the agents.
await context.SendMessageAsync(new TurnToken(emitEvents: true), cancellationToken: cancellationToken);
return routeBuilder
.AddHandler<List<ChatMessage>>(this.RouteMessages)
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
}
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
private sealed class ConcurrentAggregationExecutor() :
Executor<ChatMessage>("ConcurrentAggregationExecutor")
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
{
private readonly List<ChatMessage> _messages = [];
/// <summary>
/// Handles incoming messages from the agents and aggregates their responses.
/// </summary>
/// <param name="message">The message from the agent</param>
/// <param name="message">The messages from the agent</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this._messages.Add(message);
this._messages.AddRange(message);
if (this._messages.Count == 2)
{
@@ -97,21 +97,21 @@ internal sealed class ConcurrentStartExecutor() :
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
internal sealed class ConcurrentAggregationExecutor() :
Executor<ChatMessage>("ConcurrentAggregationExecutor")
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
{
private readonly List<ChatMessage> _messages = [];
/// <summary>
/// Handles incoming messages from the agents and aggregates their responses.
/// </summary>
/// <param name="message">The message from the agent</param>
/// <param name="message">The messages from the agent</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task representing the asynchronous operation</returns>
public override async ValueTask HandleAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this._messages.Add(message);
this._messages.AddRange(message);
if (this._messages.Count == 2)
{
@@ -184,6 +184,7 @@ internal sealed class Program
private async Task<ExternalRequest?> MonitorAndDisposeWorkflowRunAsync(Checkpointed<StreamingRun> run, object? response = null)
{
// Always dispose the run when done.
await using IAsyncDisposable disposeRun = run;
bool hasStreamed = false;
@@ -231,7 +232,7 @@ internal sealed class Program
}
else
{
await run.Run.DisposeAsync();
// Yield to handle the external request
return requestInfo.Request;
}
break;
@@ -326,7 +327,7 @@ internal sealed class Program
}
}
return default;
return null; // No request to handle
}
/// <summary>
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Azure.AI.OpenAI;
using Azure.Identity;
using Azure.Monitor.OpenTelemetry.Exporter;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace WorkflowAsAnAgentObservabilitySample;
/// <summary>
/// This sample shows how to enable OpenTelemetry observability for workflows when
/// using them as <see cref="AIAgent"/>s.
///
/// In this example, we create a workflow that uses two language agents to process
/// input concurrently, one that responds in French and another that responds in English.
///
/// You will interact with the workflow in an interactive loop, sending messages and receiving
/// streaming responses from the workflow as if it were an agent who responds in both languages.
///
/// OpenTelemetry observability is enabled at multiple levels:
/// 1. At the chat client level, capturing telemetry for interactions with the Azure OpenAI service.
/// 2. At the agent level, capturing telemetry for agent operations.
/// 3. At the workflow level, capturing telemetry for workflow execution.
///
/// Traces will be sent to an Aspire dashboard via an OTLP endpoint, and optionally to
/// Azure Monitor if an Application Insights connection string is provided.
///
/// Learn how to set up an Aspire dashboard here:
/// https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone?tabs=bash
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - Foundational samples should be completed first.
/// - This sample uses concurrent processing.
/// - An Azure OpenAI endpoint and deployment name.
/// - An Application Insights resource for telemetry (optional).
/// </remarks>
public static class Program
{
private const string SourceName = "Workflow.ApplicationInsightsSample";
private static readonly ActivitySource s_activitySource = new(SourceName);
private static async Task Main()
{
// Set up observability
var applicationInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
var otlpEndpoint = Environment.GetEnvironmentVariable("OTLP_ENDPOINT") ?? "http://localhost:4317";
var resourceBuilder = ResourceBuilder
.CreateDefault()
.AddService("WorkflowSample");
var traceProviderBuilder = Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(resourceBuilder)
.AddSource("Microsoft.Agents.AI.*") // Agent Framework telemetry
.AddSource("Microsoft.Extensions.AI.*") // Extensions AI telemetry
.AddSource(SourceName);
traceProviderBuilder.AddOtlpExporter(options => options.Endpoint = new Uri(otlpEndpoint));
if (!string.IsNullOrWhiteSpace(applicationInsightsConnectionString))
{
traceProviderBuilder.AddAzureMonitorTraceExporter(options => options.ConnectionString = applicationInsightsConnectionString);
}
using var traceProvider = traceProviderBuilder.Build();
// Set up the Azure OpenAI client
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
var chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseOpenTelemetry(sourceName: SourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the chat client level
.Build();
// Start a root activity for the application
using var activity = s_activitySource.StartActivity("main");
Console.WriteLine($"Operation/Trace ID: {Activity.Current?.TraceId}");
// Create the workflow and turn it into an agent with OpenTelemetry instrumentation
var workflow = WorkflowHelper.GetWorkflow(chatClient, SourceName);
var agent = new OpenTelemetryAgent(workflow.AsAgent("workflow-agent", "Workflow Agent"), SourceName)
{
EnableSensitiveData = true // enable sensitive data at the agent level such as prompts and responses
};
var thread = agent.GetNewThread();
// Start an interactive loop to interact with the workflow as if it were an agent
while (true)
{
Console.WriteLine();
Console.Write("User (or 'exit' to quit): ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
await ProcessInputAsync(agent, thread, input);
}
// Helper method to process user input and display streaming responses. To display
// multiple interleaved responses correctly, we buffer updates by message ID and
// re-render all messages on each update.
static async Task ProcessInputAsync(AIAgent agent, AgentThread thread, string input)
{
Dictionary<string, List<AgentRunResponseUpdate>> buffer = [];
await foreach (AgentRunResponseUpdate update in agent.RunStreamingAsync(input, thread))
{
if (update.MessageId is null || string.IsNullOrEmpty(update.Text))
{
// skip updates that don't have a message ID or text
continue;
}
Console.Clear();
if (!buffer.TryGetValue(update.MessageId, out List<AgentRunResponseUpdate>? value))
{
value = [];
buffer[update.MessageId] = value;
}
value.Add(update);
foreach (var (messageId, segments) in buffer)
{
string combinedText = string.Concat(segments);
Console.WriteLine($"{segments[0].AuthorName}: {combinedText}");
Console.WriteLine();
}
}
}
}
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Azure.Monitor.OpenTelemetry.Exporter" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WorkflowAsAnAgentObservabilitySample;
internal static class WorkflowHelper
{
/// <summary>
/// Creates a workflow that uses two language agents to process input concurrently.
/// </summary>
/// <param name="chatClient">The chat client to use for the agents</param>
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
/// <returns>A workflow that processes input using two language agents</returns>
internal static Workflow GetWorkflow(IChatClient chatClient, string sourceName)
{
// Create executors
var startExecutor = new ConcurrentStartExecutor();
var aggregationExecutor = new ConcurrentAggregationExecutor();
AIAgent frenchAgent = GetLanguageAgent("French", chatClient, sourceName);
AIAgent englishAgent = GetLanguageAgent("English", chatClient, sourceName);
// Build the workflow by adding executors and connecting them
return new WorkflowBuilder(startExecutor)
.AddFanOutEdge(startExecutor, targets: [frenchAgent, englishAgent])
.AddFanInEdge(aggregationExecutor, sources: [frenchAgent, englishAgent])
.WithOutputFrom(aggregationExecutor)
.Build();
}
/// <summary>
/// Creates a language agent for the specified target language.
/// </summary>
/// <param name="targetLanguage">The target language for translation</param>
/// <param name="chatClient">The chat client to use for the agent</param>
/// <param name="sourceName">The source name for OpenTelemetry instrumentation</param>
/// <returns>An AIAgent configured for the specified language</returns>
private static AIAgent GetLanguageAgent(string targetLanguage, IChatClient chatClient, string sourceName) =>
new ChatClientAgent(
chatClient,
instructions: $"You're a helpful assistant who always responds in {targetLanguage}.",
name: $"{targetLanguage}Agent"
)
.AsBuilder()
.UseOpenTelemetry(sourceName, configure: (cfg) => cfg.EnableSensitiveData = true) // enable telemetry at the agent level
.Build();
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
{
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
return routeBuilder
.AddHandler<List<ChatMessage>>(this.RouteMessages)
.AddHandler<TurnToken>(this.RouteTurnTokenAsync);
}
private ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
}
private ValueTask RouteTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
private sealed class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
{
private readonly List<ChatMessage> _messages = [];
/// <summary>
/// Handles incoming messages from the agents and aggregates their responses.
/// </summary>
/// <param name="message">The message from the agent</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
public override async ValueTask HandleAsync(List<ChatMessage> message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
this._messages.AddRange(message);
if (this._messages.Count == 2)
{
var formattedMessages = string.Join(Environment.NewLine, this._messages.Select(m => $"{m.Text}"));
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
}
}
@@ -19,6 +19,7 @@ Please begin with the [Foundational](./_Foundational) samples in order. These th
| [Multi-Service Workflows](./_Foundational/05_MultiModelService) | Shows using multiple AI services in the same workflow |
| [Sub-Workflows](./_Foundational/06_SubWorkflows) | Demonstrates composing workflows hierarchically by embedding workflows as executors |
| [Mixed Workflow with Agents and Executors](./_Foundational/07_MixedWorkflowAgentsAndExecutors) | Shows how to mix agents and executors with adapter pattern for type conversion and protocol handling |
| [Writer-Critic Workflow](./_Foundational/08_WriterCriticWorkflow) | Demonstrates iterative refinement with quality gates, max iteration safety, multiple message handlers, and conditional routing for feedback loops |
Once completed, please proceed to other samples listed below.
@@ -20,7 +20,9 @@ public static class Program
private static async Task Main()
{
// Create the executors
UppercaseExecutor uppercase = new();
Func<string, string> uppercaseFunc = s => s.ToUpperInvariant();
var uppercase = uppercaseFunc.BindAsExecutor("UppercaseExecutor");
ReverseTextExecutor reverse = new();
// Build the workflow by connecting executors sequentially
@@ -40,23 +42,6 @@ public static class Program
}
}
/// <summary>
/// First executor: converts input text to uppercase.
/// </summary>
internal sealed class UppercaseExecutor() : Executor<string, string>("UppercaseExecutor")
{
/// <summary>
/// Processes the input message by converting it to uppercase.
/// </summary>
/// <param name="message">The input text to convert</param>
/// <param name="context">Workflow context for accessing workflow services and adding events</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.
/// The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The input text converted to uppercase</returns>
public override ValueTask<string> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(message.ToUpperInvariant()); // The return value will be sent as a message along an edge to subsequent executors
}
/// <summary>
/// Second executor: reverses the input text and completes the workflow.
/// </summary>
@@ -40,7 +40,7 @@ public static class Program
.Build();
// Step 2: Configure the sub-workflow as an executor for use in the parent workflow
ExecutorIsh subWorkflowExecutor = subWorkflow.ConfigureSubWorkflow("TextProcessingSubWorkflow");
ExecutorBinding subWorkflowExecutor = subWorkflow.BindAsExecutor("TextProcessingSubWorkflow");
// Step 3: Build a main workflow that uses the sub-workflow as an executor
Console.WriteLine("Building main workflow that uses the sub-workflow as an executor...\n");
@@ -138,7 +138,7 @@ I cannot process this request as it appears to contain unsafe content.
## What You'll Learn
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorIsh` internally
1. **How to mix executors and agents** - Understanding that both are treated as `ExecutorBinding` internally
2. **When to use executors vs agents** - Executors for deterministic logic, agents for AI-powered decisions
3. **How to process agent outputs** - Using executors to sync, format, or aggregate agent responses
4. **Building complex pipelines** - Chaining multiple heterogeneous components together
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>WriterCriticWorkflow</RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AzureAI\Microsoft.Agents.AI.AzureAI.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -0,0 +1,409 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Extensions.AI;
namespace WriterCriticWorkflow;
/// <summary>
/// This sample demonstrates an iterative refinement workflow between Writer and Critic agents.
///
/// The workflow implements a content creation and review loop that:
/// 1. Writer creates initial content based on the user's request
/// 2. Critic reviews the content and provides feedback using structured output
/// 3. If approved: Summary executor presents the final content
/// 4. If rejected: Writer revises based on feedback (loops back)
/// 5. Continues until approval or max iterations (3) is reached
///
/// This pattern is useful when you need:
/// - Iterative content improvement through feedback loops
/// - Quality gates with reviewer approval
/// - Maximum iteration limits to prevent infinite loops
/// - Conditional workflow routing based on agent decisions
/// - Structured output for reliable decision-making
///
/// Key Learning: Workflows can implement loops with conditional edges, shared state,
/// and structured output for robust agent decision-making.
/// </summary>
/// <remarks>
/// Pre-requisites:
/// - Previous foundational samples should be completed first.
/// - An Azure OpenAI chat completion deployment must be configured.
/// </remarks>
public static class Program
{
public const int MaxIterations = 3;
private static async Task Main()
{
Console.WriteLine("\n=== Writer-Critic Iteration Workflow ===\n");
Console.WriteLine($"Writer and Critic will iterate up to {MaxIterations} times until approval.\n");
// Set up the Azure OpenAI client
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
IChatClient chatClient = new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential()).GetChatClient(deploymentName).AsIChatClient();
// Create executors for content creation and review
WriterExecutor writer = new(chatClient);
CriticExecutor critic = new(chatClient);
SummaryExecutor summary = new(chatClient);
// Build the workflow with conditional routing based on critic's decision
WorkflowBuilder workflowBuilder = new WorkflowBuilder(writer)
.AddEdge(writer, critic)
.AddSwitch(critic, sw => sw
.AddCase<CriticDecision>(cd => cd?.Approved == true, summary)
.AddCase<CriticDecision>(cd => cd?.Approved == false, writer))
.WithOutputFrom(summary);
// Execute the workflow with a sample task
// The workflow loops back to Writer if content is rejected,
// or proceeds to Summary if approved. State tracking ensures we don't loop forever.
Console.WriteLine(new string('=', 80));
Console.WriteLine("TASK: Write a short blog post about AI ethics (200 words)");
Console.WriteLine(new string('=', 80) + "\n");
const string InitialTask = "Write a 200-word blog post about AI ethics. Make it thoughtful and engaging.";
Workflow workflow = workflowBuilder.Build();
await ExecuteWorkflowAsync(workflow, InitialTask);
Console.WriteLine("\nâś… Sample Complete: Writer-Critic iteration demonstrates conditional workflow loops\n");
Console.WriteLine("Key Concepts Demonstrated:");
Console.WriteLine(" âś“ Iterative refinement loop with conditional routing");
Console.WriteLine(" âś“ Shared workflow state for iteration tracking");
Console.WriteLine($" âś“ Max iteration cap ({MaxIterations}) for safety");
Console.WriteLine(" âś“ Multiple message handlers in a single executor");
Console.WriteLine(" âś“ Streaming support with structured output\n");
}
private static async Task ExecuteWorkflowAsync(Workflow workflow, string input)
{
// Execute in streaming mode to see real-time progress
await using StreamingRun run = await InProcessExecution.StreamAsync<string>(workflow, input);
// Watch the workflow events
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case AgentRunUpdateEvent agentUpdate:
// Stream agent output in real-time
if (!string.IsNullOrEmpty(agentUpdate.Update.Text))
{
Console.Write(agentUpdate.Update.Text);
}
break;
case WorkflowOutputEvent output:
Console.WriteLine("\n\n" + new string('=', 80));
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("âś… FINAL APPROVED CONTENT");
Console.ResetColor();
Console.WriteLine(new string('=', 80));
Console.WriteLine();
Console.WriteLine(output.Data);
Console.WriteLine();
Console.WriteLine(new string('=', 80));
break;
}
}
}
}
// ====================================
// Shared State for Iteration Tracking
// ====================================
/// <summary>
/// Tracks the current iteration and conversation history across workflow executions.
/// </summary>
internal sealed class FlowState
{
public int Iteration { get; set; } = 1;
public List<ChatMessage> History { get; } = [];
}
/// <summary>
/// Constants for accessing the shared flow state in workflow context.
/// </summary>
internal static class FlowStateShared
{
public const string Scope = "FlowStateScope";
public const string Key = "singleton";
}
/// <summary>
/// Helper methods for reading and writing shared flow state.
/// </summary>
internal static class FlowStateHelpers
{
public static async Task<FlowState> ReadFlowStateAsync(IWorkflowContext context)
{
FlowState? state = await context.ReadStateAsync<FlowState>(FlowStateShared.Key, scopeName: FlowStateShared.Scope);
return state ?? new FlowState();
}
public static ValueTask SaveFlowStateAsync(IWorkflowContext context, FlowState state)
=> context.QueueStateUpdateAsync(FlowStateShared.Key, state, scopeName: FlowStateShared.Scope);
}
// ====================================
// Data Transfer Objects
// ====================================
/// <summary>
/// Structured output schema for the Critic's decision.
/// Uses JsonPropertyName and Description attributes for OpenAI's JSON schema.
/// </summary>
[Description("Critic's review decision including approval status and feedback")]
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated via JSON deserialization")]
internal sealed class CriticDecision
{
[JsonPropertyName("approved")]
[Description("Whether the content is approved (true) or needs revision (false)")]
public bool Approved { get; set; }
[JsonPropertyName("feedback")]
[Description("Specific feedback for improvements if not approved, empty if approved")]
public string Feedback { get; set; } = "";
// Non-JSON properties for workflow use
[JsonIgnore]
public string Content { get; set; } = "";
[JsonIgnore]
public int Iteration { get; set; }
}
// ====================================
// Custom Executors
// ====================================
/// <summary>
/// Executor that creates or revises content based on user requests or critic feedback.
/// This executor demonstrates multiple message handlers for different input types.
/// </summary>
internal sealed class WriterExecutor : Executor
{
private readonly AIAgent _agent;
public WriterExecutor(IChatClient chatClient) : base("Writer")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Writer",
instructions: """
You are a skilled writer. Create clear, engaging content.
If you receive feedback, carefully revise the content to address all concerns.
Maintain the same topic and length requirements.
"""
);
}
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder) =>
routeBuilder
.AddHandler<string, ChatMessage>(this.HandleInitialRequestAsync)
.AddHandler<CriticDecision, ChatMessage>(this.HandleRevisionRequestAsync);
/// <summary>
/// Handles the initial writing request from the user.
/// </summary>
private async ValueTask<ChatMessage> HandleInitialRequestAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
/// <summary>
/// Handles revision requests from the critic with feedback.
/// </summary>
private async ValueTask<ChatMessage> HandleRevisionRequestAsync(
CriticDecision decision,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
string prompt = "Revise the following content based on this feedback:\n\n" +
$"Feedback: {decision.Feedback}\n\n" +
$"Original Content:\n{decision.Content}";
return await this.HandleAsyncCoreAsync(new ChatMessage(ChatRole.User, prompt), context, cancellationToken);
}
/// <summary>
/// Core implementation for generating content (initial or revised).
/// </summary>
private async Task<ChatMessage> HandleAsyncCoreAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"\n=== Writer (Iteration {state.Iteration}) ===\n");
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
string text = sb.ToString();
state.History.Add(new ChatMessage(ChatRole.Assistant, text));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
return new ChatMessage(ChatRole.User, text);
}
}
/// <summary>
/// Executor that reviews content and decides whether to approve or request revisions.
/// Uses structured output with streaming for reliable decision-making.
/// </summary>
internal sealed class CriticExecutor : Executor<ChatMessage, CriticDecision>
{
private readonly AIAgent _agent;
public CriticExecutor(IChatClient chatClient) : base("Critic")
{
this._agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
Name = "Critic",
Instructions = """
You are a constructive critic. Review the content and provide specific feedback.
Always try to provide actionable suggestions for improvement and strive to identify improvement points.
Only approve if the content is high quality, clear, and meets the original requirements and you see no improvement points.
Provide your decision as structured output with:
- approved: true if content is good, false if revisions needed
- feedback: specific improvements needed (empty if approved)
Be concise but specific in your feedback.
""",
ChatOptions = new()
{
ResponseFormat = ChatResponseFormat.ForJsonSchema<CriticDecision>()
}
});
}
public override async ValueTask<CriticDecision> HandleAsync(
ChatMessage message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
FlowState state = await FlowStateHelpers.ReadFlowStateAsync(context);
Console.WriteLine($"=== Critic (Iteration {state.Iteration}) ===\n");
// Use RunStreamingAsync to get streaming updates, then deserialize at the end
IAsyncEnumerable<AgentRunResponseUpdate> updates = this._agent.RunStreamingAsync(message, cancellationToken: cancellationToken);
// Stream the output in real-time (for any rationale/explanation)
await foreach (AgentRunResponseUpdate update in updates)
{
if (!string.IsNullOrEmpty(update.Text))
{
Console.Write(update.Text);
}
}
Console.WriteLine("\n");
// Convert the stream to a response and deserialize the structured output
AgentRunResponse response = await updates.ToAgentRunResponseAsync(cancellationToken);
CriticDecision decision = response.Deserialize<CriticDecision>(JsonSerializerOptions.Web);
Console.WriteLine($"Decision: {(decision.Approved ? "✅ APPROVED" : "❌ NEEDS REVISION")}");
if (!string.IsNullOrEmpty(decision.Feedback))
{
Console.WriteLine($"Feedback: {decision.Feedback}");
}
Console.WriteLine();
// Safety: approve if max iterations reached
if (!decision.Approved && state.Iteration >= Program.MaxIterations)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"⚠️ Max iterations ({Program.MaxIterations}) reached - auto-approving");
Console.ResetColor();
decision.Approved = true;
decision.Feedback = "";
}
// Increment iteration ONLY if rejecting (will loop back to Writer)
if (!decision.Approved)
{
state.Iteration++;
}
// Store the decision in history
state.History.Add(new ChatMessage(ChatRole.Assistant,
$"[Decision: {(decision.Approved ? "Approved" : "Needs Revision")}] {decision.Feedback}"));
await FlowStateHelpers.SaveFlowStateAsync(context, state);
// Populate workflow-specific fields
decision.Content = message.Text ?? "";
decision.Iteration = state.Iteration;
return decision;
}
}
/// <summary>
/// Executor that presents the final approved content to the user.
/// </summary>
internal sealed class SummaryExecutor : Executor<CriticDecision, ChatMessage>
{
private readonly AIAgent _agent;
public SummaryExecutor(IChatClient chatClient) : base("Summary")
{
this._agent = new ChatClientAgent(
chatClient,
name: "Summary",
instructions: """
You present the final approved content to the user.
Simply output the polished content - no additional commentary needed.
"""
);
}
public override async ValueTask<ChatMessage> HandleAsync(
CriticDecision message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine("=== Summary ===\n");
string prompt = $"Present this approved content:\n\n{message.Content}";
StringBuilder sb = new();
await foreach (AgentRunResponseUpdate update in this._agent.RunStreamingAsync(new ChatMessage(ChatRole.User, prompt), cancellationToken: cancellationToken))
{
if (!string.IsNullOrEmpty(update.Text))
{
sb.Append(update.Text);
}
}
ChatMessage result = new(ChatRole.Assistant, sb.ToString());
await context.YieldOutputAsync(result, cancellationToken);
return result;
}
}
@@ -2,7 +2,6 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
@@ -28,7 +27,7 @@ public static class A2AAgentCardExtensions
/// <param name="httpClient">The <see cref="HttpClient"/> to use for HTTP requests.</param>
/// <param name="loggerFactory">The logger factory for enabling logging within the agent.</param>
/// <returns>An <see cref="AIAgent"/> instance backed by the A2A agent.</returns>
public static async Task<AIAgent> GetAIAgentAsync(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
public static AIAgent GetAIAgent(this AgentCard card, HttpClient? httpClient = null, ILoggerFactory? loggerFactory = null)
{
// Create the A2A client using the agent URL from the card.
var a2aClient = new A2AClient(new Uri(card.Url), httpClient);
@@ -42,6 +42,6 @@ public static class A2ACardResolverExtensions
// Obtain the agent card from the resolver.
var agentCard = await resolver.GetAgentCardAsync(cancellationToken).ConfigureAwait(false);
return await agentCard.GetAIAgentAsync(httpClient, loggerFactory).ConfigureAwait(false);
return agentCard.GetAIAgent(httpClient, loggerFactory);
}
}
@@ -11,7 +11,7 @@ namespace Microsoft.Agents.AI;
/// <remarks>
/// <para>
/// <see cref="AIContext"/> serves as a container for contextual information that <see cref="AIContextProvider"/> instances
/// can supply to enhance AI model interactions. This context is combined across multiple providers and merged with
/// can supply to enhance AI model interactions. This context is merged with
/// the agent's base configuration before being passed to the underlying AI model.
/// </para>
/// <para>
@@ -24,7 +24,7 @@ namespace Microsoft.Agents.AI;
/// </list>
/// </para>
/// <para>
/// Context information is transient by default and applies only to the current invocation, though messages
/// Context information is transient by default and applies only to the current invocation, however messages
/// added through the <see cref="Messages"/> property will be permanently incorporated into the conversation history.
/// </para>
/// </remarks>
@@ -18,25 +18,25 @@ namespace Microsoft.Agents.AI;
/// An AI context provider is a component that participates in the agent invocation lifecycle by:
/// <list type="bullet">
/// <item><description>Listening to changes in conversations</description></item>
/// <item><description>Providing additional context to AI models or agents before invocation</description></item>
/// <item><description>Providing additional context to agents during invocation</description></item>
/// <item><description>Supplying additional function tools for enhanced capabilities</description></item>
/// <item><description>Processing invocation results for state management or learning</description></item>
/// </list>
/// </para>
/// <para>
/// Context providers operate through a two-phase lifecycle: they are called before invocation via
/// <see cref="InvokingAsync"/> to provide context, and optionally called after invocation via
/// Context providers operate through a two-phase lifecycle: they are called at the start of invocation via
/// <see cref="InvokingAsync"/> to provide context, and optionally called at the end of invocation via
/// <see cref="InvokedAsync"/> to process results.
/// </para>
/// </remarks>
public abstract class AIContextProvider
{
/// <summary>
/// Called immediately before an AI model or agent is invoked to provide additional context.
/// Called at the start of agent invocation to provide additional context.
/// </summary>
/// <param name="context">Contains the request context including the messages that will be sent to the AI model or agent.</param>
/// <param name="context">Contains the request context including the caller provided messages that will be used by the agent for this invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be provided to the AI model or agent.</returns>
/// <returns>A task that represents the asynchronous operation. The task result contains the <see cref="AIContext"/> with additional context to be used by the agent during this invocation.</returns>
/// <remarks>
/// <para>
/// Implementers can load any additional context required at this time, such as:
@@ -47,14 +47,11 @@ public abstract class AIContextProvider
/// <item><description>Injecting contextual messages from conversation history</description></item>
/// </list>
/// </para>
/// <para>
/// The returned context will be combined with context from other providers before being passed to the AI model or agent.
/// </para>
/// </remarks>
public abstract ValueTask<AIContext> InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default);
/// <summary>
/// Called immediately after an AI model or agent has been invoked to process the results.
/// Called at the end of the agent invocation to process the invocation results.
/// </summary>
/// <param name="context">Contains the invocation context including request messages, response messages, and any exception that occurred.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
@@ -123,8 +120,8 @@ public abstract class AIContextProvider
/// Contains the context information provided to <see cref="InvokingAsync(InvokingContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about the upcoming AI model or agent invocation, including the messages
/// that will be sent. Context providers can use this information to determine what additional context
/// This class provides context about the invocation before the underlying AI model is invoked, including the messages
/// that will be used. Context providers can use this information to determine what additional context
/// should be provided for the invocation.
/// </remarks>
public class InvokingContext
@@ -132,7 +129,7 @@ public abstract class AIContextProvider
/// <summary>
/// Initializes a new instance of the <see cref="InvokingContext"/> class with the specified request messages.
/// </summary>
/// <param name="requestMessages">The messages to be sent to the AI model or agent for this invocation.</param>
/// <param name="requestMessages">The messages to be used by the agent for this invocation.</param>
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokingContext(IEnumerable<ChatMessage> requestMessages)
{
@@ -140,11 +137,10 @@ public abstract class AIContextProvider
}
/// <summary>
/// Gets the messages that will be sent to the AI model or agent for this invocation.
/// Gets the caller provided messages that will be used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the conversation history
/// and new messages that will be processed by the AI model or agent.
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
}
@@ -153,8 +149,8 @@ public abstract class AIContextProvider
/// Contains the context information provided to <see cref="InvokedAsync(InvokedContext, CancellationToken)"/>.
/// </summary>
/// <remarks>
/// This class provides context about a completed AI model or agent invocation, including both the
/// request messages that were sent and the response messages that were generated. It also indicates
/// This class provides context about a completed agent invocation, including both the
/// request messages that were used and the response messages that were generated. It also indicates
/// whether the invocation succeeded or failed.
/// </remarks>
public class InvokedContext
@@ -162,30 +158,41 @@ public abstract class AIContextProvider
/// <summary>
/// Initializes a new instance of the <see cref="InvokedContext"/> class with the specified request messages.
/// </summary>
/// <param name="requestMessages">The messages that were sent to the AI model or agent for this invocation.</param>
/// <param name="requestMessages">The caller provided messages that were used by the agent for this invocation.</param>
/// <param name="aiContextProviderMessages">The messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.</param>
/// <exception cref="ArgumentNullException"><paramref name="requestMessages"/> is <see langword="null"/>.</exception>
public InvokedContext(IEnumerable<ChatMessage> requestMessages)
public InvokedContext(IEnumerable<ChatMessage> requestMessages, IEnumerable<ChatMessage>? aiContextProviderMessages)
{
this.RequestMessages = requestMessages ?? throw new ArgumentNullException(nameof(requestMessages));
this.AIContextProviderMessages = aiContextProviderMessages;
}
/// <summary>
/// Gets the messages that were sent to the AI model or agent for this invocation.
/// Gets the caller provided messages that were used by the agent for this invocation.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the conversation history
/// and new messages that were processed by the AI model or agent.
/// A collection of <see cref="ChatMessage"/> instances representing new messages that were provided by the caller.
/// This does not include any <see cref="AIContextProvider"/> supplied messages.
/// </value>
public IEnumerable<ChatMessage> RequestMessages { get; }
/// <summary>
/// Gets the collection of response messages generated by the AI model or agent if the invocation succeeded.
/// Gets the messages provided by the <see cref="AIContextProvider"/> for this invocation, if any.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the response from the AI model or agent,
/// A collection of <see cref="ChatMessage"/> instances that were provided by the <see cref="AIContextProvider"/>,
/// and were used by the agent as part of the invocation.
/// </value>
public IEnumerable<ChatMessage>? AIContextProviderMessages { get; }
/// <summary>
/// Gets the collection of response messages generated during this invocation if the invocation succeeded.
/// </summary>
/// <value>
/// A collection of <see cref="ChatMessage"/> instances representing the response,
/// or <see langword="null"/> if the invocation failed or did not produce response messages.
/// </value>
public IEnumerable<ChatMessage>? ResponseMessages { get; init; }
public IEnumerable<ChatMessage>? ResponseMessages { get; set; }
/// <summary>
/// Gets the <see cref="Exception"/> that was thrown during the invocation, if the invocation failed.
@@ -193,6 +200,6 @@ public abstract class AIContextProvider
/// <value>
/// The exception that caused the invocation to fail, or <see langword="null"/> if the invocation succeeded.
/// </value>
public Exception? InvokeException { get; init; }
public Exception? InvokeException { get; set; }
}
}
@@ -131,42 +131,86 @@ public sealed class Mem0Provider : AIContextProvider
Environment.NewLine,
context.RequestMessages.Where(m => !string.IsNullOrWhiteSpace(m.Text)).Select(m => m.Text));
var memories = (await this._client.SearchAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
queryText,
cancellationToken).ConfigureAwait(false)).ToList();
var contextInstructions = memories.Count == 0
? null
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
if (this._logger is not null)
try
{
this._logger.LogInformation("Mem0AIContextProvider retrieved {Count} memories.", memories.Count);
if (contextInstructions is not null)
var memories = (await this._client.SearchAsync(
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId,
queryText,
cancellationToken).ConfigureAwait(false)).ToList();
var outputMessageText = memories.Count == 0
? null
: $"{this._contextPrompt}\n{string.Join(Environment.NewLine, memories)}";
if (this._logger is not null)
{
this._logger.LogTrace("Mem0AIContextProvider instructions: {Instructions}", contextInstructions);
this._logger.LogInformation(
"Mem0AIContextProvider: Retrieved {Count} memories. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
memories.Count,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
if (outputMessageText is not null)
{
this._logger.LogTrace(
"Mem0AIContextProvider: Search Results\nInput:{Input}\nOutput:{MessageText}\nApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
queryText,
outputMessageText,
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
}
}
}
return new AIContext
return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, outputMessageText)]
};
}
catch (ArgumentException)
{
Messages = [new ChatMessage(ChatRole.User, contextInstructions)]
};
throw;
}
catch (Exception ex)
{
this._logger?.LogError(
ex,
"Mem0AIContextProvider: Failed to search Mem0 for memories due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
return new AIContext();
}
}
/// <inheritdoc />
public override async ValueTask InvokedAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
// Persist request and response messages after invocation.
await this.PersistMessagesAsync(context.RequestMessages, cancellationToken).ConfigureAwait(false);
if (context.ResponseMessages is not null)
if (context.InvokeException is not null)
{
await this.PersistMessagesAsync(context.ResponseMessages, cancellationToken).ConfigureAwait(false);
return; // Do not update memory on failed invocations.
}
try
{
// Persist request and response messages after invocation.
await this.PersistMessagesAsync(context.RequestMessages.Concat(context.ResponseMessages ?? []), cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
this._logger?.LogError(
ex,
"Mem0AIContextProvider: Failed to send messages to Mem0 due to error. ApplicationId: '{ApplicationId}', AgentId: '{AgentId}', ThreadId: '{ThreadId}', UserId: '{UserId}'",
this.ApplicationId,
this.AgentId,
this.ThreadId,
this.UserId);
}
}
@@ -5,7 +5,6 @@
<TargetFrameworks Condition="'$(Configuration)' == 'Debug'">$(ProjectsDebugTargetFrameworks)</TargetFrameworks>
<VersionSuffix>preview</VersionSuffix>
<!-- Disable packing until we are ready to release this as a nuget -->
<IsPackable>false</IsPackable>
</PropertyGroup>
<PropertyGroup>
@@ -14,6 +13,9 @@
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
@@ -19,12 +19,12 @@ internal sealed class WorkflowModelBuilder : IModelBuilder<Func<object?, bool>>
Debug.WriteLine($"> CONNECT: {source.Id} => {target.Id}{(condition is null ? string.Empty : " (?)")}");
this.WorkflowBuilder.AddEdge(
GetExecutorIsh(source),
GetExecutorIsh(target),
GetExecutorBinding(source),
GetExecutorBinding(target),
condition);
}
private static ExecutorIsh GetExecutorIsh(IModeledAction action) =>
private static ExecutorBinding GetExecutorBinding(IModeledAction action) =>
action switch
{
RequestPortAction port => port.RequestPort,
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for an AI agent, including configuration options for event emission.
/// </summary>
/// <param name="Agent">The AI agent.</param>
/// <param name="EmitEvents">Specifies whether the agent should emit events. If null, the default behavior is applied.</param>
public record AIAgentBinding(AIAgent Agent, bool EmitEvents = false)
: ExecutorBinding(Throw.IfNull(Agent).Name ?? Throw.IfNull(Agent.Id),
(_) => new(new AIAgentHostExecutor(Agent, EmitEvents)),
typeof(AIAgentHostExecutor),
Agent)
{
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -40,7 +40,7 @@ public static partial class AgentWorkflowBuilder
// Create a builder that chains the agents together in sequence. The workflow simply begins
// with the first agent in the sequence.
WorkflowBuilder? builder = null;
ExecutorIsh? previous = null;
ExecutorBinding? previous = null;
foreach (var agent in agents)
{
AgentRunStreamingExecutor agentExecutor = new(agent, includeInputInOutput: true);
@@ -125,8 +125,8 @@ public static partial class AgentWorkflowBuilder
// so that the final accumulator receives a single list of messages from each agent. Otherwise, the
// accumulator would not be able to determine what came from what agent, as there's currently no
// provenance tracking exposed in the workflow context passed to a handler.
ExecutorIsh[] agentExecutors = (from agent in agents select (ExecutorIsh)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorIsh[] accumulators = [.. from agent in agentExecutors select (ExecutorIsh)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
ExecutorBinding[] agentExecutors = (from agent in agents select (ExecutorBinding)new AgentRunStreamingExecutor(agent, includeInputInOutput: false)).ToArray();
ExecutorBinding[] accumulators = [.. from agent in agentExecutors select (ExecutorBinding)new CollectChatMessagesExecutor($"Batcher/{agent.Id}")];
builder.AddFanOutEdge(start, targets: agentExecutors);
for (int i = 0; i < agentExecutors.Length; i++)
{
@@ -141,7 +141,7 @@ public static partial class AgentWorkflowBuilder
Func<string, string, ValueTask<ConcurrentEndExecutor>> endFactory =
(string _, string __) => new(new ConcurrentEndExecutor(agentExecutors.Length, aggregator));
ExecutorIsh end = endFactory.ConfigureFactory(ConcurrentEndExecutor.ExecutorId);
ExecutorBinding end = endFactory.BindExecutor(ConcurrentEndExecutor.ExecutorId);
builder.AddFanInEdge(end, sources: accumulators);
@@ -8,23 +8,40 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
internal class ChatProtocolExecutorOptions
/// <summary>
/// Provides configuration options for <see cref="ChatProtocolExecutor"/>.
/// </summary>
public class ChatProtocolExecutorOptions
{
/// <summary>
/// Gets or sets the chat role to use when converting string messages to <see cref="ChatMessage"/> instances.
/// If set, the executor will accept string messages and convert them to chat messages with this role.
/// </summary>
public ChatRole? StringMessageChatRole { get; set; }
}
// TODO: Make this a public type (in a later PR; todo: make an issue)
internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
/// <summary>
/// Provides a base class for executors that implement the Agent Workflow Chat Protocol.
/// This executor maintains a list of chat messages and processes them when a turn is taken.
/// </summary>
public abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage>>
{
private readonly static Func<List<ChatMessage>> s_initFunction = () => [];
private readonly ChatRole? _stringMessageChatRole;
internal ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
/// <summary>
/// Initializes a new instance of the <see cref="ChatProtocolExecutor"/> class.
/// </summary>
/// <param name="id">The unique identifier for this executor instance. Cannot be null or empty.</param>
/// <param name="options">Optional configuration settings for the executor. If null, default options are used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
protected ChatProtocolExecutor(string id, ChatProtocolExecutorOptions? options = null, bool declareCrossRunShareable = false)
: base(id, () => [], declareCrossRunShareable: declareCrossRunShareable)
{
this._stringMessageChatRole = options?.StringMessageChatRole;
}
/// <inheritdoc/>
protected override RouteBuilder ConfigureRoutes(RouteBuilder routeBuilder)
{
if (this._stringMessageChatRole.HasValue)
@@ -40,6 +57,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
.AddHandler<TurnToken>(this.TakeTurnAsync);
}
/// <summary>
/// Adds a single chat message to the accumulated messages for the current turn.
/// </summary>
/// <param name="message">The chat message to add.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
protected ValueTask AddMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
@@ -52,6 +76,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
}
}
/// <summary>
/// Adds multiple chat messages to the accumulated messages for the current turn.
/// </summary>
/// <param name="messages">The collection of chat messages to add.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
protected ValueTask AddMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(ForwardMessageAsync, context, cancellationToken: cancellationToken);
@@ -64,6 +95,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
}
}
/// <summary>
/// Handles a turn token by processing all accumulated chat messages and then resetting the message state.
/// </summary>
/// <param name="token">The turn token that triggers message processing.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public ValueTask TakeTurnAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return this.InvokeWithStateAsync(InvokeTakeTurnAsync, context, cancellationToken: cancellationToken);
@@ -81,5 +119,13 @@ internal abstract class ChatProtocolExecutor : StatefulExecutor<List<ChatMessage
}
}
/// <summary>
/// When overridden in a derived class, processes the accumulated chat messages for a single turn.
/// </summary>
/// <param name="messages">The list of chat messages accumulated since the last turn.</param>
/// <param name="context">The workflow context in which the executor executes.</param>
/// <param name="emitEvents">Indicates whether events should be emitted during processing. If null, the default behavior is used.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
protected abstract ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default);
}
@@ -12,7 +12,7 @@ internal sealed record class ExecutorInfo(TypeId ExecutorType, string ExecutorId
this.ExecutorType.IsMatch(executor.GetType())
&& this.ExecutorId == executor.Id;
public bool IsMatch(ExecutorRegistration registration) =>
this.ExecutorType.IsMatch(registration.ExecutorType)
&& this.ExecutorId == registration.Id;
public bool IsMatch(ExecutorBinding binding) =>
this.ExecutorType.IsMatch(binding.ExecutorType)
&& this.ExecutorId == binding.Id;
}
@@ -9,10 +9,10 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
internal static class RepresentationExtensions
{
public static ExecutorInfo ToExecutorInfo(this ExecutorRegistration registration)
public static ExecutorInfo ToExecutorInfo(this ExecutorBinding binding)
{
Throw.IfNull(registration);
return new ExecutorInfo(new TypeId(registration.ExecutorType), registration.Id);
Throw.IfNull(binding);
return new ExecutorInfo(new TypeId(binding.ExecutorType), binding.Id);
}
public static EdgeInfo ToEdgeInfo(this Edge edge)
@@ -38,8 +38,8 @@ internal static class RepresentationExtensions
Throw.IfNull(workflow);
Dictionary<string, ExecutorInfo> executors =
workflow.Registrations.Values.ToDictionary(
keySelector: registration => registration.Id,
workflow.ExecutorBindings.Values.ToDictionary(
keySelector: binding => binding.Id,
elementSelector: ToExecutorInfo);
Dictionary<string, List<EdgeInfo>> edges = workflow.Edges.Keys.ToDictionary(
@@ -47,10 +47,10 @@ internal sealed class WorkflowInfo
}
// Validate the executors
if (workflow.Registrations.Count != this.Executors.Count ||
if (workflow.ExecutorBindings.Count != this.Executors.Count ||
this.Executors.Keys.Any(
executorId => workflow.Registrations.TryGetValue(executorId, out ExecutorRegistration? registration)
&& !this.Executors[executorId].IsMatch(registration)))
executorId => workflow.ExecutorBindings.TryGetValue(executorId, out ExecutorBinding? binding)
&& !this.Executors[executorId].IsMatch(binding)))
{
return false;
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
// TODO: Unwrap the Configured object, just like for SubworkflowBinding
internal record ConfiguredExecutorBinding(Configured<Executor> ConfiguredExecutor, Type ExecutorType)
: ExecutorBinding(Throw.IfNull(ConfiguredExecutor).Id,
ConfiguredExecutor.BoundFactoryAsync,
ExecutorType,
ConfiguredExecutor.Raw)
{
/// <inheritdoc/>
public override bool IsSharedInstance { get; } = ConfiguredExecutor.Raw is Executor;
protected override async ValueTask<bool> ResetCoreAsync()
{
if (this.ConfiguredExecutor.Raw is IResettableExecutor resettable)
{
await resettable.ResetAsync().ConfigureAwait(false);
}
return false;
}
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows.Execution;
/// <summary>
/// A custom IAsyncEnumerable implementation that reads from a ChannelReader,
/// and suppresses OperationCanceledException when the cancellation token is triggered.
/// </summary>
internal sealed class NonThrowingChannelReaderAsyncEnumerable<T>(ChannelReader<T> reader) : IAsyncEnumerable<T>
{
private class Enumerator(ChannelReader<T> reader, CancellationToken cancellationToken) : IAsyncEnumerator<T>
{
private T? _current;
public T Current => this._current ?? throw new InvalidOperationException("Enumeration not started.");
public ValueTask DisposeAsync()
{
// no-op - the reader should not be disposed.
return default;
}
/// <summary>
/// Moves to the next item in the channel.
/// </summary>
/// <returns>If successful, returns <c>true</c>, otherwise <c>false</c>.</returns>
public async ValueTask<bool> MoveNextAsync()
{
try
{
bool hasData = await reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false);
if (hasData)
{
this._current = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
return true;
}
}
catch (OperationCanceledException)
{
// Swallow cancellation exceptions to prevent throwing from the enumerator
// Enables clean cancellation and aligns with the expected behavior of IAsyncEnumerable.
}
return false;
}
}
/// <summary>
/// Returns an async enumerator that reads items from the channel.
/// If cancellation is requested, the enumeration exits silently without throwing.
/// </summary>
/// <param name="cancellationToken">An optional cancellation token from the caller.</param>
/// <returns>An async enumerator over the channel items.</returns>
public IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default)
=> new Enumerator(reader, cancellationToken);
}
@@ -2,10 +2,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.Workflows.Execution;
@@ -15,6 +17,9 @@ namespace Microsoft.Agents.AI.Workflows.Execution;
/// </summary>
internal sealed class StreamingRunEventStream : IRunEventStream
{
private static readonly string s_namespace = typeof(StreamingRunEventStream).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
private readonly Channel<WorkflowEvent> _eventChannel;
private readonly ISuperStepRunner _stepRunner;
private readonly InputWaiter _inputWaiter;
@@ -58,6 +63,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Subscribe to events - they will flow directly to the channel as they're raised
this._stepRunner.OutgoingEvents.EventRaised += OnEventRaisedAsync;
using Activity? activity = s_activitySource.StartActivity(ActivityNames.WorkflowRun);
activity?.SetTag(Tags.WorkflowId, this._stepRunner.StartExecutorId).SetTag(Tags.RunId, this._stepRunner.RunId);
try
{
// Wait for the first input before starting
@@ -65,6 +73,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowStarted));
while (!linkedSource.Token.IsCancellationRequested)
{
@@ -99,9 +108,17 @@ internal sealed class StreamingRunEventStream : IRunEventStream
{
// Expected during shutdown
}
catch (Exception e)
catch (Exception ex)
{
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(e), linkedSource.Token).ConfigureAwait(false);
if (activity != null)
{
activity.AddEvent(new ActivityEvent(EventNames.WorkflowError, tags: new() {
{ Tags.ErrorType, ex.GetType().FullName },
{ Tags.BuildErrorMessage, ex.Message },
}));
activity.CaptureException(ex);
}
await this._eventChannel.Writer.WriteAsync(new WorkflowErrorEvent(ex), linkedSource.Token).ConfigureAwait(false);
}
finally
{
@@ -110,6 +127,7 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Mark as ended when run loop exits
this._runStatus = RunStatus.Ended;
activity?.AddEvent(new ActivityEvent(EventNames.WorkflowCompleted));
}
async ValueTask OnEventRaisedAsync(object? sender, WorkflowEvent e)
@@ -139,11 +157,9 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Get the current epoch - we'll only respond to completion signals from this epoch or later
int myEpoch = Volatile.Read(ref this._completionEpoch) + 1;
// Simply read from channel - all coordination is handled by Channel infrastructure
// Note: When cancellation is requested, ReadAllAsync may throw OperationCanceledException
// or may complete the enumeration. We check IsCancellationRequested explicitly at superstep
// boundaries to ensure clean cancellation.
await foreach (WorkflowEvent evt in this._eventChannel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
// Use custom async enumerable to avoid exceptions on cancellation.
NonThrowingChannelReaderAsyncEnumerable<WorkflowEvent> eventStream = new(this._eventChannel.Reader);
await foreach (WorkflowEvent evt in eventStream.WithCancellation(cancellationToken).ConfigureAwait(false))
{
// Filter out internal signals used for run loop coordination
if (evt is InternalHaltSignal completionSignal)
@@ -27,6 +27,8 @@ public abstract class Executor : IIdentified
private static readonly string s_namespace = typeof(Executor).Namespace!;
private static readonly ActivitySource s_activitySource = new(s_namespace);
// TODO: Add overloads for binding with a configuration/options object once the Configured<T> hierarchy goes away.
/// <summary>
/// Initialize the executor with a unique identifier
/// </summary>
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the binding information for a workflow executor, including its identifier, factory method, type, and
/// optional raw value.
/// </summary>
/// <param name="Id">The unique identifier for the executor in the workflow.</param>
/// <param name="FactoryAsync">A factory function that creates an instance of the executor. The function accepts two string parameters and returns
/// a ValueTask containing the created Executor instance.</param>
/// <param name="ExecutorType">The type of the executor. Must be a type derived from Executor.</param>
/// <param name="RawValue">An optional raw value associated with the binding.</param>
public abstract record class ExecutorBinding(string Id, Func<string, ValueTask<Executor>>? FactoryAsync, Type ExecutorType, object? RawValue = null)
: IIdentified,
IEquatable<IIdentified>,
IEquatable<string>
{
/// <summary>
/// Gets a value indicating whether the binding is a placeholder (i.e., does not have a factory method defined).
/// </summary>
[MemberNotNullWhen(false, nameof(FactoryAsync))]
public bool IsPlaceholder => this.FactoryAsync == null;
/// <summary>
/// Gets a value whether the executor created from this binding is a shared instance across all runs.
/// </summary>
public abstract bool IsSharedInstance { get; }
/// <summary>
/// Gets a value whether instances of the executor created from this binding can be used in concurrent runs
/// from the same <see cref="Workflow"/> instance.
/// </summary>
public abstract bool SupportsConcurrentSharedExecution { get; }
/// <summary>
/// Gets a value whether instances of the executor created from this binding can be reset between subsequent
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
/// cref="SupportsConcurrentSharedExecution"/>.
/// </summary>
public abstract bool SupportsResetting { get; }
/// <inheritdoc/>
public override string ToString() => $"{this.Id}:{(this.IsPlaceholder ? ":<unbound>" : this.ExecutorType.Name)}";
private Executor CheckId(Executor executor)
{
if (executor.Id != this.Id)
{
throw new InvalidOperationException(
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
}
return executor;
}
internal async ValueTask<Executor> CreateInstanceAsync(string runId)
=> !this.IsPlaceholder
? this.CheckId(await this.FactoryAsync(runId).ConfigureAwait(false))
: throw new InvalidOperationException(
$"Cannot create executor with ID '{this.Id}': Binding ({this.GetType().Name}) is a placeholder.");
/// <inheritdoc/>
public virtual bool Equals(ExecutorBinding? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(IIdentified? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(string? other) =>
other is not null && other == this.Id;
internal ValueTask<bool> TryResetAsync()
{
// Non-shared instances do not need resetting
if (!this.IsSharedInstance)
{
return new(true);
}
// If the executor supports concurrent use, then resetting is a no-op.
if (!this.SupportsResetting)
{
return new(false);
}
return this.ResetCoreAsync();
}
/// <summary>
/// Resets the executor's shared resources to their initial state. Must be overridden by bindings that support
/// resetting.
/// </summary>
/// <exception cref="InvalidOperationException"></exception>
protected virtual ValueTask<bool> ResetCoreAsync() => throw new InvalidOperationException("ExecutorBindings that support resetting must override ResetCoreAsync()");
/// <inheritdoc/>
public override int GetHashCode() => this.Id.GetHashCode();
/// <summary>
/// Defines an implicit conversion from an Executor to a <see cref="ExecutorBinding"/>.
/// </summary>
/// <param name="executor">The Executor instance to convert.</param>
public static implicit operator ExecutorBinding(Executor executor) => executor.BindExecutor();
/// <summary>
/// Defines an implicit conversion from a string identifier to an <see cref="ExecutorPlaceholder"/>.
/// </summary>
/// <param name="id">The string identifier to convert to a placeholder.</param>
public static implicit operator ExecutorBinding(string id) => new ExecutorPlaceholder(id);
/// <summary>
/// Defines an implicit conversion from a <see cref="RequestPort "/>to an <see cref="ExecutorBinding"/>.
/// </summary>
/// <param name="port">The RequestPort instance to convert.</param>
public static implicit operator ExecutorBinding(RequestPort port) => port.BindAsExecutor();
/// <summary>
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorBinding"/> instance.
/// </summary>
/// <param name="agent"></param>
public static implicit operator ExecutorBinding(AIAgent agent) => agent.BindAsExecutor();
}
@@ -0,0 +1,434 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension methods for configuring executors and functions as <see cref="ExecutorBinding"/> instances.
/// </summary>
public static class ExecutorBindingExtensions
{
/// <summary>
/// Configures an <see cref="Executor"/> instance for use in a workflow.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
/// </remarks>
/// <param name="executor">The executor instance.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance wrapping the specified <see cref="Executor"/>.</returns>
public static ExecutorBinding BindExecutor(this Executor executor)
=> new ExecutorInstanceBinding(executor);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), id: typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> factoryAsync.BindExecutor();
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> BindExecutor<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> factoryAsync.BindExecutor(id);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id and options.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <param name="options">An optional parameter specifying the options.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorBinding BindExecutor<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
return new ConfiguredExecutorBinding(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor));
}
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id and options.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <param name="options">An optional parameter specifying the options.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that resolves to the result of the factory call when messages get sent to it.</returns>
[Obsolete("Use BindExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
=> factoryAsync.BindExecutor(id, options);
private static ConfiguredExecutorBinding ToBinding<TInput>(this FunctionExecutor<TInput> executor, Delegate raw)
=> new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput>, Executor>(),
typeof(FunctionExecutor<TInput>));
private static ConfiguredExecutorBinding ToBinding<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw)
=> new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
typeof(FunctionExecutor<TInput, TOutput>));
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An ExecutorRegistration instance representing the configured sub-workflow executor.</returns>
[Obsolete("Use BindAsExecutor() instead")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static ExecutorBinding ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
=> workflow.BindAsExecutor(id, options);
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance representing the configured sub-workflow executor.</returns>
public static ExecutorBinding BindAsExecutor(this Workflow workflow, string id, ExecutorOptions? options = null)
=> new SubworkflowBinding(workflow, id, options);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, __) => messageHandlerAsync(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, IWorkflowContext, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Func<TInput, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask>)((input, _, ct) => messageHandlerAsync(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, __) => messageHandler(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, IWorkflowContext> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, ctx, __) => messageHandler(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput>(this Action<TInput, CancellationToken> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Action<TInput, IWorkflowContext, CancellationToken>)((input, _, ct) => messageHandler(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandlerAsync);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, __) => messageHandlerAsync(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, ctx, __) => messageHandlerAsync(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>>)((input, _, ct) => messageHandlerAsync(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(id, messageHandler, options, declareCrossRunShareable: threadsafe).ToBinding(messageHandler);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, __) => messageHandler(input)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, ctx, __) => messageHandler(input, ctx)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based message handler as an executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandler">A delegate that defines the function to execute for each input message.</param>
/// <param name="id">An optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TOutput>(this Func<TInput, CancellationToken, TOutput> messageHandler, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> ((Func<TInput, IWorkflowContext, CancellationToken, TOutput>)((input, _, ct) => messageHandler(input, ct)))
.BindAsExecutor(id, options, threadsafe);
/// <summary>
/// Configures a function-based aggregating executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An <see cref="ExecutorBinding"/> instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorBinding BindAsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
/// <summary>
/// Configure an <see cref="AIAgent"/> as an executor for use in a workflow.
/// </summary>
/// <param name="agent">The agent instance.</param>
/// <param name="emitEvents">Specifies whether the agent should emit streaming events.</param>
/// <returns>An <see cref="AIAgentBinding"/> instance that wraps the provided agent.</returns>
public static ExecutorBinding BindAsExecutor(this AIAgent agent, bool emitEvents = false)
=> new AIAgentBinding(agent, emitEvents);
/// <summary>
/// Configure a <see cref="RequestPort"/> as an executor for use in a workflow.
/// </summary>
/// <param name="port">The port configuration.</param>
/// <param name="allowWrappedRequests">Specifies whether the port should accept requests already wrapped in
/// <see cref="ExternalRequest"/>.</param>
/// <returns>A <see cref="RequestPortBinding"/> instance that wraps the provided port.</returns>
public static ExecutorBinding BindAsExecutor(this RequestPort port, bool allowWrappedRequests = true)
=> new RequestPortBinding(port, allowWrappedRequests);
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for a shared executor instance, including configuration options
/// for event emission.
/// </summary>
/// <param name="ExecutorInstance">The executor instance to bind. Cannot be null.</param>
public record ExecutorInstanceBinding(Executor ExecutorInstance)
: ExecutorBinding(Throw.IfNull(ExecutorInstance).Id,
(_) => new(ExecutorInstance),
ExecutorInstance.GetType(),
ExecutorInstance)
{
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => this.ExecutorInstance.IsCrossRunShareable;
/// <inheritdoc/>
public override bool SupportsResetting => this.ExecutorInstance is IResettableExecutor;
/// <inheritdoc/>
public override bool IsSharedInstance => true;
/// <inheritdoc/>
protected override async ValueTask<bool> ResetCoreAsync()
{
if (this.ExecutorInstance is IResettableExecutor resettable)
{
await resettable.ResetAsync().ConfigureAwait(false);
return true;
}
return false;
}
}
@@ -1,369 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Extension methods for configuring executors and functions as <see cref="ExecutorIsh"/> instances.
/// </summary>
public static class ExecutorIshConfigurationExtensions
{
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, using the
/// type name as the id.
/// </summary>
/// <remarks>
/// Note that Executor Ids must be unique within a workflow.
///
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((config, runId) => factoryAsync(config.Id, runId), typeof(TExecutor).Name, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor>(this Func<string, string, ValueTask<TExecutor>> factoryAsync, string id)
where TExecutor : Executor
=> ConfigureFactory<TExecutor, ExecutorOptions>((_, runId) => factoryAsync(id, runId), id, options: null);
/// <summary>
/// Configures a factory method for creating an <see cref="Executor"/> of type <typeparamref name="TExecutor"/>, with
/// the specified id and options.
/// </summary>
/// <remarks>
/// Although this will generally result in a delay-instantiated <see cref="Executor"/> once messages are available
/// for it, it will be instantiated if a <see cref="ProtocolDescriptor"/> for the <see cref="Workflow"/> is requested,
/// and it is the starting executor.
/// </remarks>
/// <typeparam name="TExecutor">The type of the resulting executor</typeparam>
/// <typeparam name="TOptions">The type of options object to be passed to the factory method.</typeparam>
/// <param name="factoryAsync">The factory method.</param>
/// <param name="id">An id for the executor to be instantiated.</param>
/// <param name="options">An optional parameter specifying the options.</param>
/// <returns>An ExecutorIsh instance that resolves to the result of the factory call when messages get sent to it.</returns>
public static ExecutorIsh ConfigureFactory<TExecutor, TOptions>(this Func<Config<TOptions>, string, ValueTask<TExecutor>> factoryAsync, string id, TOptions? options = null)
where TExecutor : Executor
where TOptions : ExecutorOptions
{
Configured<TExecutor, TOptions> configured = new(factoryAsync, id, options);
return new ExecutorIsh(configured.Super<TExecutor, Executor, TOptions>(), typeof(TExecutor), ExecutorIsh.Type.Executor);
}
private static ExecutorIsh ToExecutorIsh<TInput>(this FunctionExecutor<TInput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput>, Executor>(),
typeof(FunctionExecutor<TInput>),
ExecutorIsh.Type.Function);
private static ExecutorIsh ToExecutorIsh<TInput, TOutput>(this FunctionExecutor<TInput, TOutput> executor, Delegate raw) => new(Configured.FromInstance(executor, raw: raw)
.Super<FunctionExecutor<TInput, TOutput>, Executor>(),
typeof(FunctionExecutor<TInput, TOutput>),
ExecutorIsh.Type.Function);
/// <summary>
/// Configures a sub-workflow executor for the specified workflow, using the provided identifier and options.
/// </summary>
/// <param name="workflow">The workflow instance to be executed as a sub-workflow. Cannot be null.</param>
/// <param name="id">A unique identifier for the sub-workflow execution. Used to distinguish this sub-workflow instance.</param>
/// <param name="options">Optional configuration options for the sub-workflow executor. If null, default options are used.</param>
/// <returns>An ExecutorIsh instance representing the configured sub-workflow executor.</returns>
public static ExecutorIsh ConfigureSubWorkflow(this Workflow workflow, string id, ExecutorOptions? options = null)
{
object ownershipToken = new();
workflow.TakeOwnership(ownershipToken, subworkflow: true);
Configured<WorkflowHostExecutor, ExecutorOptions> configured = new(InitHostExecutorAsync, id, options, raw: workflow);
return new ExecutorIsh(configured.Super<WorkflowHostExecutor, Executor, ExecutorOptions>(), typeof(WorkflowHostExecutor), ExecutorIsh.Type.Workflow);
ValueTask<WorkflowHostExecutor> InitHostExecutorAsync(Config<ExecutorOptions> config, string runId)
{
return new(new WorkflowHostExecutor(config.Id, workflow, runId, ownershipToken, config.Options));
}
}
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A optional unique identifier for the executor. If <c>null</c>, will use the function argument as an id.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput>(id, messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based asynchronous message handler as an executor with the specified identifier and
/// options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TOutput">The type of output message.</typeparam>
/// <param name="messageHandlerAsync">A delegate that defines the asynchronous function to execute for each input message.</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TOutput>(this Func<TInput, IWorkflowContext, CancellationToken, ValueTask<TOutput>> messageHandlerAsync, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new FunctionExecutor<TInput, TOutput>(Throw.IfNull(id), messageHandlerAsync, options, declareCrossRunShareable: threadsafe).ToExecutorIsh(messageHandlerAsync);
/// <summary>
/// Configures a function-based aggregating executor with the specified identifier and options.
/// </summary>
/// <typeparam name="TInput">The type of input message.</typeparam>
/// <typeparam name="TAccumulate">The type of the accumulating object.</typeparam>
/// <param name="aggregatorFunc">A delegate the defines the aggregation procedure</param>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="threadsafe">Declare that the message handler may be used simultaneously by multiple runs concurrently.</param>
/// <returns>An ExecutorIsh instance that wraps the provided asynchronous message handler and configuration.</returns>
public static ExecutorIsh AsExecutor<TInput, TAccumulate>(this Func<TAccumulate?, TInput, TAccumulate?> aggregatorFunc, string id, ExecutorOptions? options = null, bool threadsafe = false)
=> new AggregatingExecutor<TInput, TAccumulate>(id, aggregatorFunc, options, declareCrossRunShareable: threadsafe);
}
/// <summary>
/// A tagged union representing an object that can function like an <see cref="Executor"/> in a <see cref="Workflow"/>,
/// or a reference to one by ID.
/// </summary>
public sealed class ExecutorIsh :
IIdentified,
IEquatable<ExecutorIsh>,
IEquatable<IIdentified>,
IEquatable<string>
{
/// <summary>
/// The type of the <see cref="ExecutorIsh"/>.
/// </summary>
public enum Type
{
/// <summary>
/// An unbound executor reference, identified only by ID.
/// </summary>
Unbound,
/// <summary>
/// An actual <see cref="Executor"/> instance.
/// </summary>
Executor,
/// <summary>
/// A function delegate to be wrapped as an executor.
/// </summary>
Function,
/// <summary>
/// An <see cref="RequestPort"/> for servicing external requests.
/// </summary>
RequestPort,
/// <summary>
/// An <see cref="AIAgent"/> instance.
/// </summary>
Agent,
/// <summary>
/// A nested <see cref="Workflow"/> instance.
/// </summary>
Workflow,
}
/// <summary>
/// Gets the type of data contained in this <see cref="ExecutorIsh" /> instance.
/// </summary>
public Type ExecutorType { get; init; }
private readonly string? _idValue;
private readonly Configured<Executor>? _configuredExecutor;
private readonly System.Type? _configuredExecutorType;
internal readonly RequestPort? _requestPortValue;
private readonly AIAgent? _aiAgentValue;
/// <summary>
/// Initializes a new instance of the <see cref="ExecutorIsh"/> class as an unbound reference by ID.
/// </summary>
/// <param name="id">A unique identifier for an <see cref="Executor"/> in the <see cref="Workflow"/></param>
public ExecutorIsh(string id)
{
this.ExecutorType = Type.Unbound;
this._idValue = Throw.IfNull(id);
}
internal ExecutorIsh(Configured<Executor> configured, System.Type configuredExecutorType, Type type)
{
this.ExecutorType = type;
this._configuredExecutor = configured;
this._configuredExecutorType = configuredExecutorType;
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified executor.
/// </summary>
/// <param name="executor">The executor instance to be wrapped.</param>
public ExecutorIsh(Executor executor)
{
this.ExecutorType = Type.Executor;
this._configuredExecutor = Configured.FromInstance(Throw.IfNull(executor));
this._configuredExecutorType = executor.GetType();
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified input port.
/// </summary>
/// <param name="port">The input port to associate to be wrapped.</param>
public ExecutorIsh(RequestPort port)
{
this.ExecutorType = Type.RequestPort;
this._requestPortValue = Throw.IfNull(port);
}
/// <summary>
/// Initializes a new instance of the ExecutorIsh class using the specified AI agent.
/// </summary>
/// <param name="aiAgent"></param>
public ExecutorIsh(AIAgent aiAgent)
{
this.ExecutorType = Type.Agent;
this._aiAgentValue = Throw.IfNull(aiAgent);
}
internal bool IsUnbound => this.ExecutorType == Type.Unbound;
/// <inheritdoc/>
public string Id => this.ExecutorType switch
{
Type.Unbound => this._idValue ?? throw new InvalidOperationException("This ExecutorIsh is unbound and has no ID."),
Type.Executor => this._configuredExecutor!.Id,
Type.RequestPort => this._requestPortValue!.Id,
Type.Agent => this._aiAgentValue!.Id,
Type.Function => this._configuredExecutor!.Id,
Type.Workflow => this._configuredExecutor!.Id,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
internal object? RawData => this.ExecutorType switch
{
Type.Unbound => this._idValue,
Type.Executor => this._configuredExecutor!.Raw ?? this._configuredExecutor,
Type.RequestPort => this._requestPortValue,
Type.Agent => this._aiAgentValue,
Type.Function => this._configuredExecutor!.Raw ?? this._configuredExecutor,
Type.Workflow => this._configuredExecutor!.Raw ?? this._configuredExecutor,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// Gets the registration details for the current executor.
/// </summary>
/// <remarks>The returned registration depends on the type of the executor. If the executor is unbound, an
/// <see cref="InvalidOperationException"/> is thrown. For other executor types, the registration includes the
/// appropriate ID, type, and provider based on the executor's configuration.</remarks>
internal ExecutorRegistration Registration => new(this.Id, this.RuntimeType, this.ExecutorProvider, this.RawData);
private System.Type RuntimeType => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"ExecutorIsh with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutorType!,
Type.RequestPort => typeof(RequestInfoExecutor),
Type.Agent => typeof(AIAgentHostExecutor),
Type.Function => this._configuredExecutorType!,
Type.Workflow => this._configuredExecutorType!,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// Gets an <see cref="Func{Executor}"/> that can be used to obtain an <see cref="Executor"/> instance
/// corresponding to this <see cref="ExecutorIsh"/>.
/// </summary>
private Func<string, ValueTask<Executor>> ExecutorProvider => this.ExecutorType switch
{
Type.Unbound => throw new InvalidOperationException($"Executor with ID '{this.Id}' is unbound."),
Type.Executor => this._configuredExecutor!.BoundFactoryAsync,
Type.RequestPort => (runId) => new(new RequestInfoExecutor(this._requestPortValue!)),
Type.Agent => (runId) => new(new AIAgentHostExecutor(this._aiAgentValue!)),
Type.Function => this._configuredExecutor!.BoundFactoryAsync,
Type.Workflow => this._configuredExecutor!.BoundFactoryAsync,
_ => throw new InvalidOperationException($"Unknown ExecutorIsh type: {this.ExecutorType}")
};
/// <summary>
/// Defines an implicit conversion from an <see cref="Executor"/> instance to an <see cref="ExecutorIsh"/> object.
/// </summary>
/// <param name="executor">The <see cref="Executor"/> instance to convert to <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(Executor executor) => new(executor);
/// <summary>
/// Defines an implicit conversion from an <see cref="RequestPort"/> to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="inputPort">The <see cref="RequestPort"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(RequestPort inputPort) => new(inputPort);
/// <summary>
/// Defines an implicit conversion from an <see cref="AIAgent"/> to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="aiAgent">The <see cref="AIAgent"/> to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(AIAgent aiAgent) => new(aiAgent);
/// <summary>
/// Defines an implicit conversion from a string to an <see cref="ExecutorIsh"/> instance.
/// </summary>
/// <param name="id">The string ID to convert to an <see cref="ExecutorIsh"/>.</param>
public static implicit operator ExecutorIsh(string id) => new(id);
/// <inheritdoc/>
public bool Equals(ExecutorIsh? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(IIdentified? other) =>
other is not null && other.Id == this.Id;
/// <inheritdoc/>
public bool Equals(string? other) =>
other is not null && other == this.Id;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj switch
{
null => false,
ExecutorIsh ish => this.Equals(ish),
IIdentified identified => this.Equals(identified),
string str => this.Equals(str),
_ => false
};
/// <inheritdoc/>
public override int GetHashCode() => this.Id.GetHashCode();
/// <inheritdoc/>
public override string ToString() => this.ExecutorType switch
{
Type.Unbound => $"'{this.Id}':<unbound>",
Type.Executor => $"'{this.Id}':{this._configuredExecutorType!.Name}",
Type.RequestPort => $"'{this.Id}':Input({this._requestPortValue!.Request.Name}->{this._requestPortValue!.Response.Name})",
Type.Agent => $"{this.Id}':AIAgent(@{this._aiAgentValue!.GetType().Name})",
Type.Function => $"'{this.Id}':{this._configuredExecutorType!.Name}",
Type.Workflow => $"'{this.Id}':{this._configuredExecutorType!.Name}",
_ => $"'{this.Id}':<unknown[{this.ExecutorType}]>"
};
}
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents a placeholder entry for an <see cref="ExecutorBinding"/>, identified by a unique ID.
/// </summary>
/// <param name="Id">The unique identifier for the placeholder registration.</param>
public record ExecutorPlaceholder(string Id)
: ExecutorBinding(Id,
null,
typeof(Executor),
Id)
{
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => false;
/// <inheritdoc/>
public override bool SupportsResetting => false;
/// <inheritdoc/>
public override bool IsSharedInstance => false;
}
@@ -1,72 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Shared.Diagnostics;
using ExecutorFactoryF = System.Func<string, System.Threading.Tasks.ValueTask<Microsoft.Agents.AI.Workflows.Executor>>;
namespace Microsoft.Agents.AI.Workflows;
internal sealed class ExecutorRegistration(string id, Type executorType, ExecutorFactoryF provider, object? rawData)
{
public string Id { get; } = Throw.IfNullOrEmpty(id);
public Type ExecutorType { get; } = Throw.IfNull(executorType);
private ExecutorFactoryF ProviderAsync { get; } = Throw.IfNull(provider);
public bool IsSharedInstance { get; } = rawData is Executor;
/// <summary>
/// Gets a value whether instances of the executor created from this registration can be reset between subsequent
/// runs from the same <see cref="Workflow"/> instance. This value is not relevant for executors that <see
/// cref="SupportsConcurrent"/>.
/// </summary>
public bool SupportsResetting { get; } = rawData is Executor &&
// Cross-Run Shareable executors are "trivially" resettable, since they
// have no on-object state.
rawData is IResettableExecutor;
/// <summary>
/// Gets a value whether instances of the executor created from this registration can be used in concurrent runs
/// from the same <see cref="Workflow"/> instance.
/// </summary>
public bool SupportsConcurrent { get; } = rawData is not Executor executor || executor.IsCrossRunShareable;
internal async ValueTask<bool> TryResetAsync()
{
// Non-shared instances do not need resetting
if (!this.IsSharedInstance)
{
return true;
}
// Technically we definitely know this is true, since if rawData is an Executor, if it was not resettable
// then we would have returned in the first condition, and if rawData is not an Executor, we would have
// returned in the second condition. That only leaves the possibility of rawData is Executor and also
// IResettableExecutor.
if (this.RawExecutorishData is IResettableExecutor resettableExecutor)
{
await resettableExecutor.ResetAsync().ConfigureAwait(false);
return true;
}
return false;
}
internal object? RawExecutorishData { get; } = rawData;
public override string ToString() => $"{this.ExecutorType.Name}({this.Id})";
private Executor CheckId(Executor executor)
{
if (executor.Id != this.Id)
{
throw new InvalidOperationException(
$"Executor ID mismatch: expected '{this.Id}', but got '{executor.Id}'.");
}
return executor;
}
public async ValueTask<Executor> CreateInstanceAsync(string runId) => this.CheckId(await this.ProviderAsync(runId).ConfigureAwait(false));
}
@@ -38,7 +38,9 @@ public class FunctionExecutor<TInput>(string id,
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync) : this(id, WrapAction(handlerSync))
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Action<TInput, IWorkflowContext, CancellationToken> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapAction(handlerSync), options, declareCrossRunShareable)
{
}
}
@@ -76,7 +78,9 @@ public class FunctionExecutor<TInput, TOutput>(string id,
/// </summary>
/// <param name="id">A unique identifier for the executor.</param>
/// <param name="handlerSync">A synchronous function to execute for each input message and workflow context.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync) : this(id, WrapFunc(handlerSync))
/// <param name="options">Configuration options for the executor. If <c>null</c>, default options will be used.</param>
/// <param name="declareCrossRunShareable">Declare that this executor may be used simultaneously by multiple runs safely.</param>
public FunctionExecutor(string id, Func<TInput, IWorkflowContext, CancellationToken, TOutput> handlerSync, ExecutorOptions? options = null, bool declareCrossRunShareable = false) : this(id, WrapFunc(handlerSync), options, declareCrossRunShareable)
{
}
}
@@ -50,12 +50,12 @@ public sealed class GroupChatWorkflowBuilder
public Workflow Build()
{
AIAgent[] agents = this._participants.ToArray();
Dictionary<AIAgent, ExecutorIsh> agentMap = agents.ToDictionary(a => a, a => (ExecutorIsh)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(a => a, a => (ExecutorBinding)new AgentRunStreamingExecutor(a, includeInputInOutput: true));
Func<string, string, ValueTask<Executor>> groupChatHostFactory =
(string id, string runId) => new(new GroupChatHost(id, agents, agentMap, this._managerFactory));
ExecutorIsh host = groupChatHostFactory.ConfigureFactory(nameof(GroupChatHost));
ExecutorBinding host = groupChatHostFactory.BindExecutor(nameof(GroupChatHost));
WorkflowBuilder builder = new(host);
foreach (var participant in agentMap.Values)
@@ -73,7 +73,7 @@ internal sealed class InProcessRunnerContext : IRunnerContext
async Task<Executor> CreateExecutorAsync(string id)
{
if (!this._workflow.Registrations.TryGetValue(executorId, out var registration))
if (!this._workflow.ExecutorBindings.TryGetValue(executorId, out var registration))
{
throw new InvalidOperationException($"Executor with ID '{executorId}' is not registered.");
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the registration details for a request port, including configuration for allowing wrapped requests.
/// </summary>
/// <param name="Port">The request port.</param>
/// <param name="AllowWrapped">true to allow wrapped requests to be handled by the port; otherwise, false.
/// The default is true.</param>
public record RequestPortBinding(RequestPort Port, bool AllowWrapped = true)
: ExecutorBinding(Throw.IfNull(Port).Id,
(_) => new ValueTask<Executor>(new RequestInfoExecutor(Port, AllowWrapped)),
typeof(RequestInfoExecutor),
Port)
{
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -10,11 +10,11 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class GroupChatHost(
string id,
AIAgent[] agents,
Dictionary<AIAgent, ExecutorIsh> agentMap,
Dictionary<AIAgent, ExecutorBinding> agentMap,
Func<IReadOnlyList<AIAgent>, GroupChatManager> managerFactory) : Executor(id), IResettableExecutor
{
private readonly AIAgent[] _agents = agents;
private readonly Dictionary<AIAgent, ExecutorIsh> _agentMap = agentMap;
private readonly Dictionary<AIAgent, ExecutorBinding> _agentMap = agentMap;
private readonly Func<IReadOnlyList<AIAgent>, GroupChatManager> _managerFactory = managerFactory;
private readonly List<ChatMessage> _pendingMessages = [];
@@ -0,0 +1,44 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Represents the workflow binding details for a subworkflow, including its instance, identifier, and optional
/// executor options.
/// </summary>
/// <param name="WorkflowInstance"></param>
/// <param name="Id"></param>
/// <param name="ExecutorOptions"></param>
public record SubworkflowBinding(Workflow WorkflowInstance, string Id, ExecutorOptions? ExecutorOptions = null)
: ExecutorBinding(Throw.IfNull(Id),
CreateWorkflowExecutorFactory(WorkflowInstance, Id, ExecutorOptions),
typeof(WorkflowHostExecutor),
WorkflowInstance)
{
private static Func<string, ValueTask<Executor>> CreateWorkflowExecutorFactory(Workflow workflow, string id, ExecutorOptions? options)
{
object ownershipToken = new();
workflow.TakeOwnership(ownershipToken, subworkflow: true);
return InitHostExecutorAsync;
ValueTask<Executor> InitHostExecutorAsync(string runId)
{
return new(new WorkflowHostExecutor(id, workflow, runId, ownershipToken, options));
}
}
/// <inheritdoc/>
public override bool IsSharedInstance => false;
/// <inheritdoc/>
public override bool SupportsConcurrentSharedExecution => true;
/// <inheritdoc/>
public override bool SupportsResetting => false;
}
@@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// </summary>
public sealed class SwitchBuilder
{
private readonly List<ExecutorIsh> _executors = [];
private readonly List<ExecutorBinding> _executors = [];
private readonly Dictionary<string, int> _executorIndicies = [];
private readonly List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> _caseMap = [];
private readonly HashSet<int> _defaultIndicies = [];
@@ -30,14 +30,14 @@ public sealed class SwitchBuilder
/// <param name="executors">One or more executors to associate with the predicate. Each executor will be invoked if the predicate matches.
/// Cannot be null.</param>
/// <returns>The current <see cref="SwitchBuilder"/> instance, allowing for method chaining.</returns>
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorIsh> executors)
public SwitchBuilder AddCase<T>(Func<T?, bool> predicate, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(predicate);
Throw.IfNull(executors);
HashSet<int> indicies = [];
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
@@ -60,11 +60,11 @@ public sealed class SwitchBuilder
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public SwitchBuilder WithDefault(params IEnumerable<ExecutorIsh> executors)
public SwitchBuilder WithDefault(params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
if (!this._executorIndicies.TryGetValue(executor.Id, out int index))
{
@@ -79,7 +79,7 @@ public sealed class SwitchBuilder
return this;
}
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorIsh source)
internal WorkflowBuilder ReduceToFanOut(WorkflowBuilder builder, ExecutorBinding source)
{
List<(Func<object?, bool> Predicate, HashSet<int> OutgoingIndicies)> caseMap = this._caseMap;
HashSet<int> defaultIndicies = this._defaultIndicies;
@@ -69,7 +69,7 @@ public static class WorkflowVisualizer
lines.Add($"{indent}\"{MapId(startExecutorId)}\" [fillcolor=lightgreen, label=\"{startExecutorId}\\n(Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.Registrations.Keys)
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
@@ -108,7 +108,7 @@ public static class WorkflowVisualizer
private static void EmitSubWorkflowsDigraph(Workflow workflow, List<string> lines, string indent)
{
foreach (var kvp in workflow.Registrations)
foreach (var kvp in workflow.ExecutorBindings)
{
var execId = kvp.Key;
var registration = kvp.Value;
@@ -145,7 +145,7 @@ public static class WorkflowVisualizer
lines.Add($"{indent}{MapId(startExecutorId)}[\"{startExecutorId} (Start)\"];");
// Add other executor nodes
foreach (var executorId in workflow.Registrations.Keys)
foreach (var executorId in workflow.ExecutorBindings.Keys)
{
if (executorId != startExecutorId)
{
@@ -264,9 +264,9 @@ public static class WorkflowVisualizer
#endif
}
private static bool TryGetNestedWorkflow(ExecutorRegistration registration, [NotNullWhen(true)] out Workflow? workflow)
private static bool TryGetNestedWorkflow(ExecutorBinding binding, [NotNullWhen(true)] out Workflow? workflow)
{
if (registration.RawExecutorishData is Workflow subWorkflow)
if (binding.RawValue is Workflow subWorkflow)
{
workflow = subWorkflow;
return true;
@@ -19,7 +19,7 @@ public class Workflow
/// <summary>
/// A dictionary of executor providers, keyed by executor ID.
/// </summary>
internal Dictionary<string, ExecutorRegistration> Registrations { get; init; } = [];
internal Dictionary<string, ExecutorBinding> ExecutorBindings { get; init; } = [];
internal Dictionary<string, HashSet<Edge>> Edges { get; init; } = [];
internal HashSet<string> OutputExecutors { get; init; } = [];
@@ -41,7 +41,7 @@ public class Workflow
/// Gets the collection of external request ports, keyed by their ID.
/// </summary>
/// <remarks>
/// Each port has a corresponding entry in the <see cref="Registrations"/> dictionary.
/// Each port has a corresponding entry in the <see cref="ExecutorBindings"/> dictionary.
/// </remarks>
public Dictionary<string, RequestPortInfo> ReflectPorts()
{
@@ -66,10 +66,10 @@ public class Workflow
/// </summary>
public string? Description { get; internal init; }
internal bool AllowConcurrent => this.Registrations.Values.All(registration => registration.SupportsConcurrent);
internal bool AllowConcurrent => this.ExecutorBindings.Values.All(registration => registration.SupportsConcurrentSharedExecution);
internal IEnumerable<string> NonConcurrentExecutorIds =>
this.Registrations.Values.Where(r => !r.SupportsConcurrent).Select(r => r.Id);
this.ExecutorBindings.Values.Where(r => !r.SupportsConcurrentSharedExecution).Select(r => r.Id);
/// <summary>
/// Initializes a new instance of the <see cref="Workflow"/> class with the specified starting executor identifier
@@ -86,12 +86,14 @@ public class Workflow
}
private bool _needsReset;
private bool HasResettable => this.Registrations.Values.Any(registration => registration.SupportsResetting);
private bool HasResettableExecutors =>
this.ExecutorBindings.Values.Any(registration => registration.SupportsResetting);
private async ValueTask<bool> TryResetExecutorRegistrationsAsync()
{
if (this.HasResettable)
if (this.HasResettableExecutors)
{
foreach (ExecutorRegistration registration in this.Registrations.Values)
foreach (ExecutorBinding registration in this.ExecutorBindings.Values)
{
// TryResetAsync returns true if the executor does not need resetting
if (!await registration.TryResetAsync().ConfigureAwait(false))
@@ -158,7 +160,7 @@ public class Workflow
});
}
this._needsReset = this.HasResettable;
this._needsReset = this.HasResettableExecutors;
this._ownedAsSubworkflow = subworkflow;
}
@@ -188,7 +190,7 @@ public class Workflow
/// a <see cref="ProtocolDescriptor"/> the protocol this <see cref="Workflow"/> follows.</returns>
public async ValueTask<ProtocolDescriptor> DescribeProtocolAsync(CancellationToken cancellationToken = default)
{
ExecutorRegistration startExecutorRegistration = this.Registrations[this.StartExecutorId];
ExecutorBinding startExecutorRegistration = this.ExecutorBindings[this.StartExecutorId];
Executor startExecutor = await startExecutorRegistration.CreateInstanceAsync(string.Empty)
.ConfigureAwait(false);
return startExecutor.DescribeProtocol();
@@ -19,7 +19,7 @@ namespace Microsoft.Agents.AI.Workflows;
/// <remarks>Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out
/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow.
/// All executors must be bound by calling into <see cref="BindExecutor"/> if they were intially specified as
/// <see cref="ExecutorIsh.Type.Unbound"/>.</remarks>
/// <see cref="ExecutorBinding.IsPlaceholder"/>.</remarks>
public class WorkflowBuilder
{
private readonly record struct EdgeConnection(string SourceId, string TargetId)
@@ -28,11 +28,11 @@ public class WorkflowBuilder
}
private int _edgeCount;
private readonly Dictionary<string, ExecutorRegistration> _executors = [];
private readonly Dictionary<string, ExecutorBinding> _executors = [];
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, RequestPort> _inputPorts = [];
private readonly Dictionary<string, RequestPort> _requestPorts = [];
private readonly HashSet<string> _outputExecutors = [];
private readonly string _startExecutorId;
@@ -46,57 +46,56 @@ public class WorkflowBuilder
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
/// </summary>
/// <param name="start">The executor that defines the starting point of the workflow. Cannot be null.</param>
public WorkflowBuilder(ExecutorIsh start)
public WorkflowBuilder(ExecutorBinding start)
{
this._startExecutorId = this.Track(start).Id;
}
private ExecutorIsh Track(ExecutorIsh executorish)
private ExecutorBinding Track(ExecutorBinding registration)
{
// If the executor is unbound, create an entry for it, unless it already exists.
// Otherwise, update the entry for it, and remove the unbound tag
if (executorish.IsUnbound && !this._executors.ContainsKey(executorish.Id))
if (registration.IsPlaceholder && !this._executors.ContainsKey(registration.Id))
{
// If this is an unbound executor, we need to track it separately
this._unboundExecutors.Add(executorish.Id);
this._unboundExecutors.Add(registration.Id);
}
else if (!executorish.IsUnbound)
else if (!registration.IsPlaceholder)
{
ExecutorRegistration incoming = executorish.Registration;
// If there is already a bound executor with this ID, we need to validate (to best efforts)
// that the two are matching (at least based on type)
if (this._executors.TryGetValue(executorish.Id, out ExecutorRegistration? existing))
if (this._executors.TryGetValue(registration.Id, out ExecutorBinding? existing))
{
if (existing.ExecutorType != incoming.ExecutorType)
if (existing.ExecutorType != registration.ExecutorType)
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {incoming.ExecutorType.Name}) is already bound.");
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {registration.ExecutorType.Name}) is already bound.");
}
if (existing.RawExecutorishData is not null &&
!ReferenceEquals(existing.RawExecutorishData, incoming.RawExecutorishData))
if (existing.RawValue is not null &&
!ReferenceEquals(existing.RawValue, registration.RawValue))
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{executorish.Id}' because an executor with the same ID but different instance is already bound.");
$"Cannot bind executor with ID '{registration.Id}' because an executor with the same ID but different instance is already bound.");
}
}
else
{
this._executors[executorish.Id] = executorish.Registration;
if (this._unboundExecutors.Contains(executorish.Id))
this._executors[registration.Id] = registration;
if (this._unboundExecutors.Contains(registration.Id))
{
this._unboundExecutors.Remove(executorish.Id);
this._unboundExecutors.Remove(registration.Id);
}
}
}
if (executorish.ExecutorType == ExecutorIsh.Type.RequestPort)
if (registration is RequestPortBinding portRegistration)
{
RequestPort port = executorish._requestPortValue!;
this._inputPorts[port.Id] = port;
RequestPort port = portRegistration.Port;
this._requestPorts[port.Id] = port;
}
return executorish;
return registration;
}
/// <summary>
@@ -106,9 +105,9 @@ public class WorkflowBuilder
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public WorkflowBuilder WithOutputFrom(params ExecutorIsh[] executors)
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
{
foreach (ExecutorIsh executor in executors)
foreach (ExecutorBinding executor in executors)
{
this._outputExecutors.Add(this.Track(executor).Id);
}
@@ -139,21 +138,21 @@ public class WorkflowBuilder
}
/// <summary>
/// Binds the specified executor to the workflow, allowing it to participate in workflow execution.
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
/// </summary>
/// <param name="executor">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
/// <param name="registration">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if the specified executor is already bound or does not exist in the workflow.</exception>
public WorkflowBuilder BindExecutor(Executor executor)
public WorkflowBuilder BindExecutor(ExecutorBinding registration)
{
if (!this._unboundExecutors.Contains(executor.Id))
if (Throw.IfNull(registration) is ExecutorPlaceholder)
{
throw new InvalidOperationException(
$"Executor with ID '{executor.Id}' is already bound or does not exist in the workflow.");
$"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " +
"You must provide a concrete executor instance or registration.");
}
this._executors[executor.Id] = new ExecutorIsh(executor).Registration;
this._unboundExecutors.Remove(executor.Id);
this.Track(registration);
return this;
}
@@ -180,7 +179,7 @@ public class WorkflowBuilder
/// <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(ExecutorIsh source, ExecutorIsh target, bool idempotent = false)
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
@@ -236,7 +235,7 @@ public class WorkflowBuilder
/// <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>(ExecutorIsh source, ExecutorIsh target, Func<T?, bool>? condition = null, bool idempotent = false)
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = 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.
@@ -273,7 +272,7 @@ public class WorkflowBuilder
/// <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>
public WorkflowBuilder AddFanOutEdge(ExecutorIsh source, params IEnumerable<ExecutorIsh> targets)
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, params IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, null, targets);
internal static Func<object?, int, IEnumerable<int>>? CreateEdgeAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? partitioner)
@@ -305,7 +304,7 @@ public class WorkflowBuilder
/// If null, messages will route to all targets.</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>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorIsh source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorIsh> targets)
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, Func<T?, int, IEnumerable<int>>? partitioner = null, params IEnumerable<ExecutorBinding> targets)
{
Throw.IfNull(source);
Throw.IfNull(targets);
@@ -339,7 +338,7 @@ public class WorkflowBuilder
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInEdge(ExecutorIsh target, params IEnumerable<ExecutorIsh> sources)
public WorkflowBuilder AddFanInEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
{
Throw.IfNull(target);
Throw.IfNull(sources);
@@ -398,9 +397,9 @@ public class WorkflowBuilder
var workflow = new Workflow(this._startExecutorId, this._name, this._description)
{
Registrations = this._executors,
ExecutorBindings = this._executors,
Edges = this._edges,
Ports = this._inputPorts,
Ports = this._requestPorts,
OutputExecutors = this._outputExecutors
};
@@ -24,7 +24,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
=> builder.ForwardMessage<TMessage>(source, condition: null, executors);
/// <summary>
@@ -38,7 +38,7 @@ public static class WorkflowBuilderExtensions
/// all messages of type <typeparamref name="TMessage"/> will be forwarded.</param>
/// <param name="executors">The target executors to which messages will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance.</returns>
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardMessage<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, Func<TMessage, bool>? condition = null, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
@@ -47,7 +47,7 @@ public static class WorkflowBuilderExtensions
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { Count: 1 })
if (executors is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
@@ -68,7 +68,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor from which messages will be forwarded.</param>
/// <param name="executors">The target executors to which messages, except those of type <typeparamref name="TMessage"/>, will be forwarded.</param>
/// <returns>The updated <see cref="WorkflowBuilder"/> instance with the added edges.</returns>
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorIsh source, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder ForwardExcept<TMessage>(this WorkflowBuilder builder, ExecutorBinding source, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(executors);
@@ -77,7 +77,7 @@ public static class WorkflowBuilderExtensions
#if NET
if (executors.TryGetNonEnumeratedCount(out int count) && count == 1)
#else
if (executors is ICollection<ExecutorIsh> { Count: 1 })
if (executors is ICollection<ExecutorBinding> { Count: 1 })
#endif
{
return builder.AddEdge(source, executors.First(), predicate);
@@ -102,7 +102,7 @@ public static class WorkflowBuilderExtensions
/// <param name="executors">An ordered array of executors to be added to the chain after the source.</param>
/// <returns>The original workflow builder instance with the specified executor chain added.</returns>
/// <exception cref="ArgumentException">Thrown if there is a cycle in the chain.</exception>
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorIsh source, bool allowRepetition = false, params IEnumerable<ExecutorIsh> executors)
public static WorkflowBuilder AddChain(this WorkflowBuilder builder, ExecutorBinding source, bool allowRepetition = false, params IEnumerable<ExecutorBinding> executors)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -139,7 +139,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor representing the external system or process to connect. Cannot be null.</param>
/// <param name="portId">The unique identifier for the input port that will handle the external call. Cannot be null.</param>
/// <returns>The original workflow builder instance with the external call added.</returns>
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorIsh source, string portId)
public static WorkflowBuilder AddExternalCall<TRequest, TResponse>(this WorkflowBuilder builder, ExecutorBinding source, string portId)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -160,7 +160,7 @@ public static class WorkflowBuilderExtensions
/// <param name="source">The source executor that determines the branching condition for the switch. Cannot be null.</param>
/// <param name="configureSwitch">An action used to configure the switch builder, specifying the branches and their conditions. Cannot be null.</param>
/// <returns>The workflow builder instance with the configured switch step added.</returns>
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorIsh source, Action<SwitchBuilder> configureSwitch)
public static WorkflowBuilder AddSwitch(this WorkflowBuilder builder, ExecutorBinding source, Action<SwitchBuilder> configureSwitch)
{
Throw.IfNull(builder);
Throw.IfNull(source);
@@ -201,11 +201,9 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
int messageCount = threadMessages.Count;
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
@@ -221,11 +219,11 @@ public sealed partial class ChatClientAgent : AIAgent
try
{
// Using the enumerator to ensure we consider the case where no updates are returned for notification.
responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(threadMessages, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
responseUpdatesEnumerator = chatClient.GetStreamingResponseAsync(inputMessagesForChatClient, chatOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -239,7 +237,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
@@ -260,7 +258,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
}
@@ -272,10 +270,10 @@ public sealed partial class ChatClientAgent : AIAgent
this.UpdateThreadWithTypeAndConversationId(safeThread, chatResponse.ConversationId);
// To avoid inconsistent state we only notify the thread of the input messages if no error occurs after the initial request.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -348,30 +346,30 @@ public sealed partial class ChatClientAgent : AIAgent
{
var inputMessages = Throw.IfNull(messages) as IReadOnlyCollection<ChatMessage> ?? messages.ToList();
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> threadMessages) =
(ChatClientAgentThread safeThread, ChatOptions? chatOptions, List<ChatMessage> inputMessagesForChatClient, IList<ChatMessage>? aiContextProviderMessages) =
await this.PrepareThreadAndMessagesAsync(thread, inputMessages, options, cancellationToken).ConfigureAwait(false);
var chatClient = this.ChatClient;
chatClient = ApplyRunOptionsTransformations(options, chatClient);
var agentName = this.GetLoggingAgentName();
var loggingAgentName = this.GetLoggingAgentName();
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType);
this._logger.LogAgentChatClientInvokingAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType);
// Call the IChatClient and notify the AIContextProvider of any failures.
TChatClientResponse chatResponse;
try
{
chatResponse = await chatClientRunFunc.Invoke(chatClient, threadMessages, chatOptions, cancellationToken).ConfigureAwait(false);
chatResponse = await chatClientRunFunc.Invoke(chatClient, inputMessagesForChatClient, chatOptions, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfFailureAsync(safeThread, ex, inputMessages, aiContextProviderMessages, cancellationToken).ConfigureAwait(false);
throw;
}
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, agentName, this._chatClientType, inputMessages.Count);
this._logger.LogAgentChatClientInvokedAgent(nameof(RunAsync), this.Id, loggingAgentName, this._chatClientType, inputMessages.Count);
// We can derive the type of supported thread from whether we have a conversation id,
// so let's update it and set the conversation id for the service thread case.
@@ -380,14 +378,14 @@ public sealed partial class ChatClientAgent : AIAgent
// Ensure that the author name is set for each message in the response.
foreach (ChatMessage chatResponseMessage in chatResponse.Messages)
{
chatResponseMessage.AuthorName ??= agentName;
chatResponseMessage.AuthorName ??= this.Name;
}
// Only notify the thread of new messages if the chatResponse was successful to avoid inconsistent message state in the thread.
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
await NotifyThreadOfNewMessagesAsync(safeThread, inputMessages.Concat(aiContextProviderMessages ?? []).Concat(chatResponse.Messages), cancellationToken).ConfigureAwait(false);
// Notify the AIContextProvider of all new messages.
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
await NotifyAIContextProviderOfSuccessAsync(safeThread, inputMessages, aiContextProviderMessages, chatResponse.Messages, cancellationToken).ConfigureAwait(false);
var agentResponse = agentResponseFactoryFunc(chatResponse);
@@ -399,11 +397,16 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Notify the <see cref="AIContextProvider"/> when an agent run succeeded, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfSuccessAsync(ChatClientAgentThread thread, IEnumerable<ChatMessage> inputMessages, IEnumerable<ChatMessage> responseMessages, CancellationToken cancellationToken)
private static async Task NotifyAIContextProviderOfSuccessAsync(
ChatClientAgentThread thread,
IEnumerable<ChatMessage> inputMessages,
IList<ChatMessage>? aiContextProviderMessages,
IEnumerable<ChatMessage> responseMessages,
CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { ResponseMessages = responseMessages },
await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { ResponseMessages = responseMessages },
cancellationToken).ConfigureAwait(false);
}
}
@@ -411,11 +414,16 @@ public sealed partial class ChatClientAgent : AIAgent
/// <summary>
/// Notify the <see cref="AIContextProvider"/> of any failure during an agent run, if there is an <see cref="AIContextProvider"/>.
/// </summary>
private static async Task NotifyAIContextProviderOfFailureAsync(ChatClientAgentThread thread, Exception ex, IEnumerable<ChatMessage> inputMessages, CancellationToken cancellationToken)
private static async Task NotifyAIContextProviderOfFailureAsync(
ChatClientAgentThread thread,
Exception ex,
IEnumerable<ChatMessage> inputMessages,
IList<ChatMessage>? aiContextProviderMessages,
CancellationToken cancellationToken)
{
if (thread.AIContextProvider is not null)
{
await thread.AIContextProvider.InvokedAsync(new(inputMessages) { InvokeException = ex },
await thread.AIContextProvider.InvokedAsync(new(inputMessages, aiContextProviderMessages) { InvokeException = ex },
cancellationToken).ConfigureAwait(false);
}
}
@@ -556,7 +564,7 @@ public sealed partial class ChatClientAgent : AIAgent
/// <param name="runOptions">Optional parameters for agent invocation.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A tuple containing the thread, chat options, and thread messages.</returns>
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> ThreadMessages)> PrepareThreadAndMessagesAsync(
private async Task<(ChatClientAgentThread AgentThread, ChatOptions? ChatOptions, List<ChatMessage> InputMessagesForChatClient, IList<ChatMessage>? AIContextProviderMessages)> PrepareThreadAndMessagesAsync(
AgentThread? thread,
IEnumerable<ChatMessage> inputMessages,
AgentRunOptions? runOptions,
@@ -582,7 +590,8 @@ public sealed partial class ChatClientAgent : AIAgent
{
throw new InvalidOperationException("Input messages are not allowed when continuing a background response using a continuation token.");
}
List<ChatMessage> threadMessages = [];
List<ChatMessage> inputMessagesForChatClient = [];
IList<ChatMessage>? aiContextProviderMessages = null;
// Populate the thread messages only if we are not continuing an existing response as it's not allowed
if (chatOptions?.ContinuationToken is null)
@@ -590,7 +599,7 @@ public sealed partial class ChatClientAgent : AIAgent
// Add any existing messages from the thread to the messages to be sent to the chat client.
if (typedThread.MessageStore is not null)
{
threadMessages.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
inputMessagesForChatClient.AddRange(await typedThread.MessageStore.GetMessagesAsync(cancellationToken).ConfigureAwait(false));
}
// If we have an AIContextProvider, we should get context from it, and update our
@@ -601,7 +610,8 @@ public sealed partial class ChatClientAgent : AIAgent
var aiContext = await typedThread.AIContextProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
if (aiContext.Messages is { Count: > 0 })
{
threadMessages.AddRange(aiContext.Messages);
inputMessagesForChatClient.AddRange(aiContext.Messages);
aiContextProviderMessages = aiContext.Messages;
}
if (aiContext.Tools is { Count: > 0 })
@@ -622,7 +632,7 @@ public sealed partial class ChatClientAgent : AIAgent
}
// Add the input messages to the end of thread messages.
threadMessages.AddRange(inputMessages);
inputMessagesForChatClient.AddRange(inputMessages);
}
// If a user provided two different thread ids, via the thread object and options, we should throw
@@ -649,7 +659,7 @@ public sealed partial class ChatClientAgent : AIAgent
chatOptions.ConversationId = typedThread.ConversationId;
}
return (typedThread, chatOptions, threadMessages);
return (typedThread, chatOptions, inputMessagesForChatClient, aiContextProviderMessages);
}
private void UpdateThreadWithTypeAndConversationId(ChatClientAgentThread thread, string? responseConversationId)
@@ -22,7 +22,7 @@ namespace Microsoft.Agents.AI.Data;
/// <para>
/// The provider supports two behaviors controlled via <see cref="TextSearchProviderOptions.SearchTime"/>:
/// <list type="bullet">
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional instructions.</description></item>
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke"/> – Automatically performs a search prior to every AI invocation and injects results as additional messages.</description></item>
/// <item><description><see cref="TextSearchProviderOptions.TextSearchBehavior.OnDemandFunctionCalling"/> – Exposes a function tool that the model may invoke to retrieve contextual information when needed.</description></item>
/// </list>
/// </para>
@@ -40,11 +40,12 @@ public sealed class TextSearchProvider : AIContextProvider
private const string DefaultContextPrompt = "## Additional Context\nConsider the following information from source documents when responding to the user:";
private const string DefaultCitationsPrompt = "Include citations to the source document with document name and link if document name and link is available.";
private readonly Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> _searchAsync;
private readonly Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> _searchAsync;
private readonly ILogger<TextSearchProvider>? _logger;
private readonly AITool[] _tools;
private readonly Queue<string> _recentMessagesText;
private readonly TextSearchProviderOptions _options;
private readonly List<ChatRole> _recentMessageRolesIncluded;
/// <summary>
/// Initializes a new instance of the <see cref="TextSearchProvider"/> class.
@@ -53,13 +54,14 @@ public sealed class TextSearchProvider : AIContextProvider
/// <param name="options">Optional configuration options.</param>
/// <param name="loggerFactory">Optional logger factory.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="searchAsync"/> is <see langword="null"/>.</exception>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessagesText = new();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
// Create the on-demand search tool (only used if behavior is OnDemandFunctionCalling)
this._tools =
@@ -85,12 +87,13 @@ public sealed class TextSearchProvider : AIContextProvider
/// If a value was not persisted or matches the defaults it will fall back to the built-in defaults.
/// Custom <see cref="TextSearchProviderOptions.ContextFormatter"/> delegates are not serialized.
/// </remarks>
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
public TextSearchProvider(Func<string, CancellationToken, Task<IEnumerable<TextSearchResult>>> searchAsync, JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, TextSearchProviderOptions? options = null, ILoggerFactory? loggerFactory = null)
{
this._searchAsync = searchAsync ?? throw new ArgumentNullException(nameof(searchAsync));
this._options = options ?? new();
Throw.IfLessThan(this._options.RecentMessageMemoryLimit, 0);
this._logger = loggerFactory?.CreateLogger<TextSearchProvider>();
this._recentMessageRolesIncluded = this._options.RecentMessageRolesIncluded ?? [ChatRole.User];
List<string>? restoredMessages = null;
@@ -119,12 +122,13 @@ public sealed class TextSearchProvider : AIContextProvider
if (this._options.SearchTime != TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke)
{
// Expose the search tool for on-demand invocation.
return new AIContext { Tools = this._tools }; // No automatic instructions injection.
return new AIContext { Tools = this._tools }; // No automatic message injection.
}
// Aggregate text from memory + current request messages.
var sbInput = new StringBuilder();
foreach (var messageText in this._recentMessagesText)
var requestMessagesText = context.RequestMessages.Where(x => !string.IsNullOrWhiteSpace(x?.Text)).Select(x => x.Text);
foreach (var messageText in this._recentMessagesText.Concat(requestMessagesText))
{
if (sbInput.Length > 0)
{
@@ -133,38 +137,35 @@ public sealed class TextSearchProvider : AIContextProvider
sbInput.Append(messageText);
}
foreach (var message in context.RequestMessages)
{
if (!string.IsNullOrWhiteSpace(message?.Text))
{
if (sbInput.Length > 0)
{
sbInput.Append('\n');
}
sbInput.Append(message.Text);
}
}
string input = sbInput.ToString();
// Search
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
IList<TextSearchSearchResult> materialized = results as IList<TextSearchSearchResult> ?? results.ToList();
if (materialized.Count == 0)
try
{
this._logger?.LogWarning("TextSearchProvider: No search results found.");
// Search
var results = await this._searchAsync(input, cancellationToken).ConfigureAwait(false);
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
if (materialized.Count == 0)
{
return new AIContext();
}
// Format search results
string formatted = this.FormatResults(materialized);
this._logger?.LogTrace("TextSearchProvider: Search Results\nInput:{Input}\nOutput:{MessageText}", input, formatted);
return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, formatted) { AdditionalProperties = new AdditionalPropertiesDictionary() { ["IsTextSearchProviderOutput"] = true } }]
};
}
catch (Exception ex)
{
this._logger?.LogError(ex, "TextSearchProvider: Failed to search for data due to error");
return new AIContext();
}
// Format search results
string formatted = this.FormatResults(materialized);
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
this._logger?.LogTrace("TextSearchProvider Input:{Input}\nContext Instructions:{Instructions}", input, formatted);
return new AIContext
{
Messages = [new ChatMessage(ChatRole.User, formatted)]
};
}
/// <inheritdoc />
@@ -176,9 +177,19 @@ public sealed class TextSearchProvider : AIContextProvider
return default; // Memory disabled.
}
if (context.InvokeException is not null)
{
return default; // Do not update memory on failed invocations.
}
var messagesText = context.RequestMessages
.Concat(context.ResponseMessages ?? [])
.Where(m => (m.Role == ChatRole.User || m.Role == ChatRole.Assistant) && !string.IsNullOrWhiteSpace(m.Text))
.Where(m =>
this._recentMessageRolesIncluded.Contains(m.Role) &&
!string.IsNullOrWhiteSpace(m.Text) &&
// Filter out any messages that were added by this class in InvokingAsync, since we don't want
// a feedback loop where previous search results are used to find new search results.
(m.AdditionalProperties == null || m.AdditionalProperties.TryGetValue("IsTextSearchProviderOutput", out bool isTextSearchProviderOutput) == false || !isTextSearchProviderOutput))
.Select(m => m.Text)
.ToList();
if (messagesText.Count > limit)
@@ -226,21 +237,21 @@ public sealed class TextSearchProvider : AIContextProvider
internal async Task<string> SearchAsync(string userQuestion, CancellationToken cancellationToken = default)
{
var results = await this._searchAsync(userQuestion, cancellationToken).ConfigureAwait(false);
IList<TextSearchSearchResult> materialized = results as IList<TextSearchSearchResult> ?? results.ToList();
string formatted = this.FormatResults(materialized);
IList<TextSearchResult> materialized = results as IList<TextSearchResult> ?? results.ToList();
string outputText = this.FormatResults(materialized);
this._logger?.LogInformation("TextSearchProvider: Retrieved {Count} search results.", materialized.Count);
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nContext Instructions:{Instructions}", userQuestion, formatted);
this._logger?.LogTrace("TextSearchProvider Input:{UserQuestion}\nOutput:{MessageText}", userQuestion, outputText);
return formatted;
return outputText;
}
/// <summary>
/// Formats search results into an instructions string for model consumption.
/// Formats search results into an output string for model consumption.
/// </summary>
/// <param name="results">The results.</param>
/// <returns>Formatted string (may be empty).</returns>
private string FormatResults(IList<TextSearchSearchResult> results)
private string FormatResults(IList<TextSearchResult> results)
{
if (this._options.ContextFormatter is not null)
{
@@ -257,15 +268,15 @@ public sealed class TextSearchProvider : AIContextProvider
for (int i = 0; i < results.Count; i++)
{
var result = results[i];
if (!string.IsNullOrWhiteSpace(result.Name))
if (!string.IsNullOrWhiteSpace(result.SourceName))
{
sb.AppendLine($"SourceDocName: {result.Name}");
sb.AppendLine($"SourceDocName: {result.SourceName}");
}
if (!string.IsNullOrWhiteSpace(result.Link))
if (!string.IsNullOrWhiteSpace(result.SourceLink))
{
sb.AppendLine($"SourceDocLink: {result.Link}");
sb.AppendLine($"SourceDocLink: {result.SourceLink}");
}
sb.AppendLine($"Contents: {result.Value}");
sb.AppendLine($"Contents: {result.Text}");
sb.AppendLine("----");
}
sb.AppendLine(this._options.CitationsPrompt ?? DefaultCitationsPrompt);
@@ -276,28 +287,28 @@ public sealed class TextSearchProvider : AIContextProvider
/// <summary>
/// Represents a single retrieved text search result.
/// </summary>
public sealed class TextSearchSearchResult
public sealed class TextSearchResult
{
/// <summary>
/// Gets or sets the display name of the source document (optional).
/// </summary>
public string? Name { get; set; }
public string? SourceName { get; set; }
/// <summary>
/// Gets or sets a link/URL to the source document (optional).
/// </summary>
public string? Link { get; set; }
public string? SourceLink { get; set; }
/// <summary>
/// Gets or sets the textual content of the retrieved chunk.
/// </summary>
public string Value { get; set; } = string.Empty;
public string Text { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the raw representation of the search result from the data source.
/// </summary>
/// <remarks>
/// If a <see cref="TextSearchSearchResult"/> is created to represent some underlying object from another object
/// If a <see cref="TextSearchResult"/> is created to represent some underlying object from another object
/// model, this property can be used to store that original object. This can be useful for debugging or
/// for enabling the <see cref="TextSearchProviderOptions.ContextFormatter"/> to access the underlying object model if needed.
/// </remarks>
@@ -30,12 +30,12 @@ public sealed class TextSearchProviderOptions
public string? FunctionToolDescription { get; set; }
/// <summary>
/// Gets or sets the context prompt prefixed to automatically injected results.
/// Gets or sets the context prompt prefixed to results.
/// </summary>
public string? ContextPrompt { get; set; }
/// <summary>
/// Gets or sets the instruction appended after automatically injected results to request citations.
/// Gets or sets the instruction appended after results to request citations.
/// </summary>
public string? CitationsPrompt { get; set; }
@@ -45,7 +45,7 @@ public sealed class TextSearchProviderOptions
/// <remarks>
/// If provided, <see cref="ContextPrompt"/> and <see cref="CitationsPrompt"/> are ignored.
/// </remarks>
public Func<IList<TextSearchProvider.TextSearchSearchResult>, string>? ContextFormatter { get; set; }
public Func<IList<TextSearchProvider.TextSearchResult>, string>? ContextFormatter { get; set; }
/// <summary>
/// Gets or sets the number of recent conversation messages (both user and assistant) to keep in memory
@@ -59,13 +59,33 @@ public sealed class TextSearchProviderOptions
/// </value>
public int RecentMessageMemoryLimit { get; set; }
/// <summary>
/// Gets or sets the list of <see cref="ChatRole"/> types to filter recent messages to
/// when deciding which recent messages to include when constructing the search input.
/// </summary>
/// <remarks>
/// <para>
/// Depending on your scenario, you may want to use only user messages, only assistant messages,
/// or both. For example, if the assistant may often provide clarifying questions or if the conversation
/// is expected to be particularly chatty, you may want to include assistant messages in the search context as well.
/// </para>
/// <para>
/// Be careful when including assistant messages though, as they may skew the search results towards
/// information that has already been provided by the assistant, rather than focusing on the user's current needs.
/// </para>
/// </remarks>
/// <value>
/// When not specified, defaults to only <see cref="ChatRole.User"/>.
/// </value>
public List<ChatRole>? RecentMessageRolesIncluded { get; set; }
/// <summary>
/// Behavior choices for the provider.
/// </summary>
public enum TextSearchBehavior
{
/// <summary>
/// Execute search prior to each invocation and inject results as instructions.
/// Execute search prior to each invocation and inject results as a message.
/// </summary>
BeforeAIInvoke,
@@ -19,6 +19,7 @@
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="System.Diagnostics.DiagnosticSource" />
</ItemGroup>
@@ -31,6 +32,7 @@
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Agents.AI.UnitTests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
</Project>
@@ -31,10 +31,10 @@ public sealed class A2AAgentCardExtensionsTests
}
[Fact]
public async Task GetAIAgentAsync_ReturnsAIAgentAsync()
public void GetAIAgent_ReturnsAIAgent()
{
// Act
var agent = await this._agentCard.GetAIAgentAsync();
var agent = this._agentCard.GetAIAgent();
// Assert
Assert.NotNull(agent);
@@ -56,7 +56,7 @@ public sealed class A2AAgentCardExtensionsTests
Parts = [new TextPart { Text = "Response" }],
});
var agent = await this._agentCard.GetAIAgentAsync(httpClient);
var agent = this._agentCard.GetAIAgent(httpClient);
// Act
await agent.RunAsync("Test input");
@@ -16,7 +16,7 @@ public class AIContextProviderTests
{
var provider = new TestAIContextProvider();
var messages = new ReadOnlyCollection<ChatMessage>([]);
var task = provider.InvokedAsync(new(messages));
var task = provider.InvokedAsync(new(messages, aiContextProviderMessages: null));
Assert.Equal(default, task);
}
@@ -37,7 +37,7 @@ public class AIContextProviderTests
[Fact]
public void InvokedContext_Constructor_ThrowsForNullMessages()
{
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!));
Assert.Throws<ArgumentNullException>(() => new AIContextProvider.InvokedContext(null!, aiContextProviderMessages: null));
}
#region GetService Method Tests
@@ -53,7 +53,7 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
// Act
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input }));
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { input }, aiContextProviderMessages: null));
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
await sut.ClearStoredMemoriesAsync();
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -77,7 +77,7 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.DoesNotContain("Caoimhe", ctxBefore.Messages?[0].Text ?? string.Empty);
// Act
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }));
await sut.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null));
var ctxAfterAdding = await GetContextWithRetryAsync(sut, question);
await sut.ClearStoredMemoriesAsync();
var ctxAfterClearing = await sut.InvokingAsync(new AIContextProvider.InvokingContext(new[] { question }));
@@ -105,7 +105,7 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.DoesNotContain("Caoimhe", ctxBefore2.Messages?[0].Text ?? string.Empty);
// Act
await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }));
await sut1.InvokedAsync(new AIContextProvider.InvokedContext(new[] { assistantIntro }, aiContextProviderMessages: null));
var ctxAfterAdding1 = await GetContextWithRetryAsync(sut1, question);
var ctxAfterAdding2 = await GetContextWithRetryAsync(sut2, question);
@@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Moq;
namespace Microsoft.Agents.AI.Mem0.UnitTests;
@@ -17,13 +18,23 @@ namespace Microsoft.Agents.AI.Mem0.UnitTests;
/// </summary>
public sealed class Mem0ProviderTests : IDisposable
{
private readonly Mock<ILogger<Mem0Provider>> _loggerMock;
private readonly Mock<ILoggerFactory> _loggerFactoryMock;
private readonly RecordingHandler _handler = new();
private readonly HttpClient _httpClient;
private readonly ILoggerFactory _loggerFactory = LoggerFactory.Create(b => b.AddProvider(new NullLoggerProvider()));
private bool _disposed;
public Mem0ProviderTests()
{
this._loggerMock = new();
this._loggerFactoryMock = new();
this._loggerFactoryMock
.Setup(f => f.CreateLogger(It.IsAny<string>()))
.Returns(this._loggerMock.Object);
this._loggerFactoryMock
.Setup(f => f.CreateLogger(typeof(Mem0Provider).FullName!))
.Returns(this._loggerMock.Object);
this._httpClient = new HttpClient(this._handler)
{
BaseAddress = new Uri("https://localhost/")
@@ -80,7 +91,7 @@ public sealed class Mem0ProviderTests : IDisposable
ThreadId = "thread",
UserId = "user"
};
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactory);
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "What is my name?") });
// Act
@@ -99,6 +110,24 @@ public sealed class Mem0ProviderTests : IDisposable
var contextMessage = Assert.Single(aiContext.Messages);
Assert.Equal(ChatRole.User, contextMessage.Role);
Assert.Contains("Name is Caoimhe", contextMessage.Text);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Information,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Retrieved 1 memories.")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Search Results\nInput:What is my name?\nOutput")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
@@ -123,8 +152,7 @@ public sealed class Mem0ProviderTests : IDisposable
};
// Act
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages)); // persists request messages
await sut.InvokedAsync(new AIContextProvider.InvokedContext(responseMessages)); // persists assistant
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
// Assert
var memoryPosts = this._handler.Requests.Where(r => r.RequestMessage.RequestUri!.AbsolutePath == "/v1/memories/" && r.RequestMessage.Method == HttpMethod.Post).ToList();
@@ -136,6 +164,60 @@ public sealed class Mem0ProviderTests : IDisposable
Assert.DoesNotContain(memoryPosts, r => ContainsOrdinal(r.RequestBody, "Tool text"));
}
[Fact]
public async Task InvokedAsync_PersistsNothingForFailedRequestAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options);
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "User text"),
new(ChatRole.System, "System text"),
new(ChatRole.Tool, "Tool text should be ignored")
};
// Act
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = null, InvokeException = new InvalidOperationException("Request Failed") });
// Assert
Assert.Empty(this._handler.Requests);
}
[Fact]
public async Task InvokedAsync_ShouldNotThrow_WhenStorageFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "a", AgentId = "b", ThreadId = "c", UserId = "d" };
var sut = new Mem0Provider(this._httpClient, options, this._loggerFactoryMock.Object);
this._handler.EnqueueEmptyInternalServerError();
var requestMessages = new List<ChatMessage>
{
new(ChatRole.User, "User text"),
new(ChatRole.System, "System text"),
new(ChatRole.Tool, "Tool text should be ignored")
};
var responseMessages = new List<ChatMessage>
{
new(ChatRole.Assistant, "Assistant text")
};
// Act
await sut.InvokedAsync(new AIContextProvider.InvokedContext(requestMessages, aiContextProviderMessages: null) { ResponseMessages = responseMessages });
// Assert
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to send messages to Mem0 due to error")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
[Fact]
public async Task ClearStoredMemoriesAsync_SendsDeleteWithQueryAsync()
{
@@ -247,6 +329,30 @@ public sealed class Mem0ProviderTests : IDisposable
await Assert.ThrowsAsync<ArgumentException>(() => sut.InvokingAsync(ctx).AsTask());
}
[Fact]
public async Task InvokingAsync_ShouldNotThrow_WhenSearchFailsAsync()
{
// Arrange
var options = new Mem0ProviderOptions { ApplicationId = "app" };
var provider = new Mem0Provider(this._httpClient, options, loggerFactory: this._loggerFactoryMock.Object);
var invokingContext = new AIContextProvider.InvokingContext(new[] { new ChatMessage(ChatRole.User, "Q?") });
// Act
var aiContext = await provider.InvokingAsync(invokingContext, CancellationToken.None);
// Assert
Assert.Null(aiContext.Messages);
Assert.Null(aiContext.Tools);
this._loggerMock.Verify(
l => l.Log(
LogLevel.Error,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("Mem0AIContextProvider: Failed to search Mem0 for memories due to error")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
private static bool ContainsOrdinal(string source, string value) => source.IndexOf(value, StringComparison.Ordinal) >= 0;
public void Dispose()
@@ -255,7 +361,6 @@ public sealed class Mem0ProviderTests : IDisposable
{
this._httpClient.Dispose();
this._handler.Dispose();
this._loggerFactory.Dispose();
this._disposed = true;
}
}
@@ -289,18 +394,7 @@ public sealed class Mem0ProviderTests : IDisposable
}
public void EnqueueEmptyOk() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.OK));
}
private sealed class NullLoggerProvider : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new NullLogger();
public void Dispose() { }
private sealed class NullLogger : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => false;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) { }
}
public void EnqueueEmptyInternalServerError() => this._responses.Enqueue(new HttpResponseMessage(System.Net.HttpStatusCode.InternalServerError));
}
}

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