Compare commits

...
Author SHA1 Message Date
Shyju Krishnankutty 84284e866b Update DurableTask.AzureManaged to Version="1.5.0" 2026-03-09 13:37:58 -07:00
Shyju Krishnankutty 4ed1e5fef1 Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0" 2026-03-09 12:46:11 -07:00
Shyju KrishnankuttyandGitHub 710c8e2069 Merge branch 'main' into feat/durable_task 2026-03-09 12:01:42 -07:00
2aaca50217 Python: Exclude conversation_id from chat completions API options (#4517)
* Python: Exclude conversation_id from chat completions options (#4315)

When a session with service_session_id is passed to an agent using the
Chat Completions client, conversation_id leaked through _prepare_options()
into AsyncCompletions.create(), causing an 'unexpected keyword argument'
error. The Responses client already excluded conversation_id but the Chat
Completions client did not.

Added conversation_id to the exclusion set in _prepare_options().

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

* Apply pre-commit auto-fixes

* Remove reproduction report artifact

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 17:03:50 +00:00
f74bda5a83 Python: Fix conversation-id propagation when chat_options is a dict (#4340)
* Fix #4305: Handle dict chat_options in _update_conversation_id

_update_conversation_id assumed chat_options had attribute access, but
ChatOptions is a TypedDict (dict). When a dict was passed, setting
.conversation_id raised AttributeError. Now checks isinstance(dict) and
uses key access for dicts, falling back to attribute access for objects.

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

* Address PR feedback: use Mapping ABC and add missing tests (#4305)

- Use collections.abc.Mapping instead of dict for isinstance check in
  _update_conversation_id, making it more robust for non-dict mapping types.
- Add test for object-style chat_options with optional options dict parameter.
- Add test verifying existing conversation_id gets overwritten (idempotent).

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

* Remove unnecessary Mapping check in _update_conversation_id (#4305)

chat_options is always a dict, so the isinstance(chat_opts, Mapping)
check and the else branch for attribute-style access are dead code.
Simplify to direct dict key assignment and remove object-style tests.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 10:56:57 +00:00
23d6d91c8f Python: [Breaking] Upgrade to azure-ai-projects 2.0+ (#4536)
* Prepare azure-ai-projects 2.0 GA compatibility

Add allow_preview support for internal AIProjectClient creation, keep backward compatibility for renamed SDK model classes, and align Azure AI/core paths and tests for GA validation workflows.

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

* upgrade to ai-project==2.0.0

* Python: remove azure-ai-projects keyword-guard paths

Assume azure-ai-projects 2.0+ in Azure AI client/provider/responses code paths by removing _supports_keyword_argument gating and related fallback branching.

Also fix pyright typing in FoundryMemoryProvider memory store calls by using ResponseInputItemParam-typed items.

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

* check fixes

* Python: remove unsupported foundry_features option

Drop foundry_features from Azure AI client and provider surfaces because azure-ai-projects 2.0.0 does not expose that create_version parameter.

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

* Python: add allow_preview to Foundry memory provider

Propagate allow_preview when FoundryMemoryProvider constructs an AIProjectClient and update tests accordingly.

Also finish wiring allow_preview through AzureAIClient-facing surfaces and related docs.

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

* aligning docstrings

* udpated lock

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 10:12:47 +00:00
d5e240b375 [BREAKING] Python: Update github-copilot-sdk integration to use ToolInvocation/ToolResult types (#4551)
* Update github_copilot package for github-copilot-sdk>=0.1.32 (#4549)

- Update requires-python from >=3.10 to >=3.11
- Remove Python 3.10 classifier
- Update mypy python_version to 3.11
- Update dependency to github-copilot-sdk>=0.1.32
- Fix ToolResult API: use snake_case kwargs (text_result_for_llm,
  result_type) instead of camelCase (textResultForLlm, resultType)
- Update test assertions to use attribute access on ToolResult
- Add ToolResult type assertions to tool handler tests

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

* Fix tests to use ToolInvocation dataclass instead of plain dict (#4549)

Update test_github_copilot_agent.py to pass ToolInvocation objects to tool
handlers instead of plain dicts, matching the github-copilot-sdk>=0.1.32 API
where ToolInvocation is a dataclass with an .arguments attribute.

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

* Add regression tests for ToolInvocation contract (#4549)

Add tests to lock in the new ToolInvocation-based calling convention:
- test_tool_handler_rejects_raw_dict_invocation: verifies passing a raw
  dict (old calling convention) raises TypeError/AttributeError
- test_tool_handler_with_empty_arguments: verifies ToolInvocation with
  empty arguments works correctly for no-arg tools

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

* Revert requires-python to >=3.10 to avoid breaking CI (#4549)

The repo CI runs with Python 3.10 (uv sync --all-packages) and all other
packages require >=3.10. Raising this package to >=3.11 would break the
shared install flow. The SDK dependency version constraint (>=0.1.32) will
enforce any Python version requirement from the SDK itself.

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

* Fix min Python version for github_copilot package to >=3.11

github-copilot-sdk>=0.1.32 requires Python>=3.11, which conflicts
with the package's declared >=3.10 minimum, breaking uv sync.

* Bump py version for GH workflows to 3.11, exclude GHCP sdk from 3.10 items

* Fix uv command

* Fixes

* Update samples

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 09:57:51 +00:00
Shyju KrishnankuttyandCopilot d03d59b347 Bump DurableTask SDK packages to 1.22.0
- DurableTask.Client: 1.19.1 -> 1.22.0
- DurableTask.Client.AzureManaged: 1.19.1 -> 1.22.0
- DurableTask.Worker: 1.19.1 -> 1.22.0
- DurableTask.Worker.AzureManaged: 1.19.1 -> 1.22.0
- Azure Functions extensions kept at original versions (1.13.1/1.0.1) due to
  host-side DurableTask.Core 3.7.0 incompatibility with newer extensions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 17:40:40 -08:00
Shyju KrishnankuttyandCopilot 22989abb3c Bump DurableTask and Azure Functions extension package versions
- DurableTask.* packages: 1.19.1 -> 1.22.0
- Functions.Worker.Extensions.DurableTask: 1.13.1 -> 1.16.0
- Functions.Worker.Extensions.DurableTask.AzureManaged: 1.0.1 -> 1.5.0 (telemetry bug fix)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 15:00:01 -08:00
Shyju KrishnankuttyandGitHub e24876364a Merge branch 'main' into feat/durable_task 2026-03-06 14:54:24 -08:00
westeyandGitHub 1ca43f9643 .NET: Add security warnings to xml comments for core components (#4527)
* Add security warnings to xml comments for core components

* Address build errors.

* Fix formatting issue

* Fix formatting issue

* Supress formatting warning

* Supress format issue in ChatHistoryMemoryProvider

* Fix remarks paragraphs
2026-03-06 19:04:22 +00:00
CopilotGitHubrogerbarretocopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
b98880df32 .NET: Update Anthropic to 12.8.0 and Anthropic.Foundry to 0.4.2 (#4475)
* Initial plan

* Update Anthropic to 12.8.0 and Anthropic.Foundry to 0.4.2

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
2026-03-06 18:57:02 +00:00
westeyandGitHub c8750cbe92 .NET: Create a sample to show bounded chat history with overflow into chat history memory (#4136)
* Create a sample to show bounded chat history with overflow into chat history memory

* Address PR comments.

* Address PR comment and fix bug
2026-03-06 18:03:43 +00:00
SergeyMenshykhandGitHub 394e9c1692 .NET: Improve skill name validation: reject consecutive hyphens and enforce directory name match (#4526)
* improve skill validation

* address pr review comments
2026-03-06 17:29:53 +00:00
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rogerbarreto
7e98b0cd29 .NET: Update HostedAgents samples to Azure.AI.AgentServer.AgentFramework 1.0.0-beta.9 and MEAI 10.3.0 (#4477)
* Initial plan

* Update HostedAgents samples to Azure.AI.AgentServer.AgentFramework 1.0.0-beta.9 and MEAI 10.3.0

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

* Fix HostedAgents samples for Microsoft.Agents.AI 1.0.0-rc2 API changes

- Rename CreateAIAgent -> AsAIAgent (AgentThreadAndHITL, AgentWithHostedMCP, AgentWithTextSearchRag)
- Rename AsAgent -> AsAIAgent (AgentsInWorkflows)
- Replace AIContextProviderFactory with AIContextProviders and simplified TextSearchProvider ctor (AgentWithTextSearchRag)
- Update Microsoft.Agents.AI.OpenAI to 1.0.0-rc2 (AgentThreadAndHITL, AgentWithTextSearchRag, AgentWithTools)
- Update Microsoft.Agents.AI.Workflows to 1.0.0-rc2 (AgentsInWorkflows)
- Add Microsoft.Agents.AI 1.0.0-rc2 reference (AgentWithHostedMCP)

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

* Update HostedAgents samples for beta.9 API changes and add missing projects to slnx

- Use DefaultAzureCredential consistently across all samples
- Add AgentThreadAndHITL, AgentWithLocalTools, AgentWithTools to slnx
- Apply dotnet format

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

* Remove unnecessary Microsoft.Agents.AI.* package references (transitive from AgentFramework)

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

* Add DefaultAzureCredential production warning comments to all HostedAgents samples

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

* Update HostedAgents READMEs to reflect DefaultAzureCredential usage

Replace AzureCliCredential references with DefaultAzureCredential in all
HostedAgents README files to match the actual sample code.

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

* Replace Microsoft.Extensions.AI.OpenAI with Microsoft.Agents.AI.OpenAI and remove AsIChatClient()

Swap package references from Microsoft.Extensions.AI.OpenAI to
Microsoft.Agents.AI.OpenAI across all 6 HostedAgents samples. This enables
using the AsAIAgent() extension directly on ChatClient/ResponsesClient
(from OpenAI.Chat/OpenAI.Responses namespaces), removing the intermediate
AsIChatClient() call in 3 samples where it was unnecessary.

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

* Use explicit types and AsAIAgent() extensions across all HostedAgents samples

Replace var with explicit types for clarity in all 6 samples. Replace
new ChatClientAgent() constructor calls with chatClient.AsAIAgent()
extension method in AgentWithLocalTools and AgentsInWorkflows.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 12:15:10 +00:00
f7e4143c61 .NET: Fix filter combine logic for ChatHistoryMemoryProvider (#4501)
* Fix filter combine logic for ChatHistoryMemoryProvider

* Replace var with explicit types in filter building code and test

Address PR review nit: use explicit types instead of var for better
readability in the filter-building logic and the new combined filter
compilation test.

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

* Fix style issues

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 11:59:50 +00:00
westeyandGitHub d8d6ac1c59 Add ServiceLifetime support for Hosting DI registration (#4476) 2026-03-06 09:39:33 +00:00
4bd5469798 Python: Improve ag-ui tests and coverage (#4442)
* Improve ag-ui tests and coverage

* fix tests paths

* Fixes

* Improve AG-UI test robustness and correctness

- Map toolName → tool_call_name in SSE helpers for TOOL_CALL_START events
- Fail loudly on malformed SSE JSON in parse_sse_response() instead of silently dropping
- Detect duplicate TOOL_CALL_START/TOOL_CALL_END in assert_tool_calls_balanced()
- Remove fragile source line reference from test docstring
- Add found guard in test_client_tool_sets_additional_properties to prevent vacuous pass

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 07:06:56 +00:00
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>markwallace-microsofteavanvalkenburgTaoChenOSUBen Thomas
1ac68f65bf Python: Fix RedisContextProvider for redisvl 0.14.0 by using AggregateHybridQuery (#3954)
* Initial plan

* Fix: Replace alpha with linear_alpha in HybridQuery for redisvl 0.14.0 compatibility

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

* Address code review: Improve test readability and add explanatory comment

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

* Add CHANGELOG entry for redisvl 0.14.0 compatibility fix

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

* Use AggregateHybridQuery instead of HybridQuery for backward compatibility

Replace HybridQuery with AggregateHybridQuery to preserve existing functionality that works with older Redis versions. The new HybridQuery in redisvl 0.14.0 requires Redis 8.4.0+ and uses a different API, while AggregateHybridQuery maintains compatibility with the original implementation.

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

* Fix test to use linear_alpha parameter matching _redis_search implementation

The test was passing alpha as a keyword argument to _redis_search(), but the
method uses linear_alpha to match the redisvl 0.14.0 AggregateHybridQuery API.

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

* Fix pyright error: use alpha parameter matching AggregateHybridQuery API

AggregateHybridQuery expects 'alpha', not 'linear_alpha'. Updated the
_redis_search method parameter and the test accordingly.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: markwallace-microsoft <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: eavanvalkenburg <13749212+eavanvalkenburg@users.noreply.github.com>
Co-authored-by: TaoChenOSU <12570346+TaoChenOSU@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-06 01:34:35 +00:00
8664d19285 Python: Propagated MCP isError flag through function middleware pipeline (#4511)
* Propagated MCP isError flag through function middleware pipeline

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

* Small update

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

* Fix CI

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 23:16:19 +00:00
ce7b5b17c1 Python: Fix as_agent() not defaulting name/description from client properties (#4484)
* Fix as_agent() not defaulting name/description from client properties

AzureAIClient.as_agent() and AzureAIAgentClient.as_agent() now fall back
to self.agent_name and self.agent_description when name/description are
not explicitly passed. This ensures Agent.name is populated for
telemetry spans without requiring callers to repeat the name.

Fixes #4471

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

* Address review: use is None checks instead of truthiness

Switch from name or self.agent_name to explicit is None checks so
that callers can intentionally pass empty strings without them being
replaced by client defaults. Added edge-case tests for empty strings.

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

* Update docstrings to document name/description defaulting behavior

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 20:12:11 +00:00
SergeyMenshykhandGitHub 8bf4235f4e Python: Forward runtime kwargs to skill resource functions (#4417)
* support code skills

* address pr review comments

* address package and syntax checks

* address pr review comments

* address pr review comment

* address failed check

* rename agentskill and agetnskillprovider

* move agent skills related assets to _skills.py

* address pr review comments

* address review comments

* support kwargs

* address pr review feedback
2026-03-05 18:01:25 +00:00
55ddd841b7 Python: Fix Python pyright package scoping and typing remediation (#4426)
* Fix Python pyright package scoping and typing remediation

Implements issue #4407 by removing the root pyright include, adding package-level pyright includes, and resolving pyright/mypy typing issues across Python packages. Also cleans unnecessary casts and applies line-level, rule-specific ignores where external libraries are too dynamic.

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

* Reduce pyright cost in handoff cloning

Simplify cloned_options construction in HandoffAgentExecutor to avoid expensive TypedDict narrowing/inference in _handoff.py, which was causing pyright to spend a long time in orchestrations.

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

* fix types

* Fix lint and type-check regressions

Resolve current Python package check failures across lint, pyright, and mypy after recent code changes, including purview/declarative pyright issues and multiple ruff simplification findings.

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

* fixed hooks

* Stabilize package tests and test tasks

Resolve cross-package non-integration test failures, simplify streaming type flow, harden locale/culture handling, and standardize package test poe tasks to exclude integration tests where applicable.

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

* lots of small fixes

* Fix current Python test regressions

Address current failing unit tests in azure-ai, bedrock, and azure-cosmos while keeping Bedrock parsing logic inline (no new static helper methods).

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

* small fixes

* small fixes

* removed pydantic from json

* final updates

* fix core

* fix tests

* fix obser

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 15:32:24 +00:00
westeyandGitHub 4a043c6c66 .NET: Switch auth sample to use Singletons (#4454)
* Switch auth sample to use Singletons

* Address PR comments

* Add comment to warn users to choose the appropriate lifetime for their service
2026-03-05 14:42:46 +00:00
+6
westeyGitHuballiscodeCopilotcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>CopilotRoger BarretoEduard van ValkenburgRishabh ChawlaPeter IbekweDmytro StrukBen ThomasEvan Mattson
3fb90a501a .NET: CI Build time end to end improvement (#4208)
* .NET: Upgrade to XUnit 3 and Microsoft Testing Platform (#4176)

* Fix copilot studio integration tests failure (#4209)

* Fix anthropic integration tests and skip reason (#4211)

* Remove accidental add of code coverage for integration tests (#4219)

* Add solution filtered parallel test run (#4226)

* Fix build paths (#4228)

* Fix coverage settings path and trait filter (#4229)

* Add project name filter to solution (#4231)

* Increase Integration Test Parallelism (#4241)

* Increase integration tests threads to 4x (#4242)

* Separate build and test into parallel jobs (#4243)

* Filter src by framework for tests build (#4244)

* Separate build and test into parallel jobs

* Filter source projects by framework for tests build

* Pre-build samples via tests to avoid timeouts (#4245)

* Separate build from run for console sample validation (#4251)

* Address PR comments (#4255)

* Merge and move scripts (#4308)

* .NET: Add Microsoft Fabric sample #3674 (#4230)

Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>

* Python: Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference (#4207)

* Phase 2: Embedding clients for Ollama, Bedrock, and Azure AI Inference

Add embedding client implementations to existing provider packages:

- OllamaEmbeddingClient: Text embeddings via Ollama's embed API
- BedrockEmbeddingClient: Text embeddings via Amazon Titan on Bedrock
- AzureAIInferenceEmbeddingClient: Text and image embeddings via Azure AI
  Inference, supporting Content | str input with separate model IDs for
  text (AZURE_AI_INFERENCE_EMBEDDING_MODEL_ID) and image
  (AZURE_AI_INFERENCE_IMAGE_EMBEDDING_MODEL_ID) endpoints

Additional changes:
- Rename EmbeddingCoT -> EmbeddingT, EmbeddingOptionsCoT -> EmbeddingOptionsT
- Add otel_provider_name passthrough to all embedding clients
- Register integration pytest marker in all packages
- Add lazy-loading namespace exports for Ollama and Bedrock embeddings
- Add image embedding sample using Cohere-embed-v3-english
- Add azure-ai-inference dependency to azure-ai package

Part of #1188

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

* Fix mypy duplicate name and ruff lint issues

- Rename second 'vector' variable to 'img_vector' in image embedding loop
- Combine nested with statements in tests
- Remove unused result assignments in tests

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

* updates from feedback

* Fix CI failures in embedding usage handling

- Fix Azure AI embedding mypy issues by normalizing vectors to list[float],
  safely accumulating optional usage token fields, and filtering None entries
  before constructing GeneratedEmbeddings
- Avoid Bandit false positive by initializing usage details as an empty dict
- Update OpenAI embedding tests to assert canonical usage keys
  (input_token_count/total_token_count)

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

---------

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

* [Purview] Mark responses as responses and fix epoch bug for python long overflow (#4225)

* .NET: Support InvokeMcpTool for declarative workflows (#4204)

* Initial implementation of InvokeMcpTool in declarative workflow

* Cleaned up sample implementation

* Updated sample comments.

* Added missing executor routing attribute

* Fix PR comments.

* Updated based on PR comments.

* Updated based on PR comments.

* Removed unnecessary using statement.

* Update Python package versions to rc2 (#4258)

- Bump core and azure-ai to 1.0.0rc2
- Bump preview packages to 1.0.0b260225
- Update dependencies to >=1.0.0rc2
- Add CHANGELOG entries for changes since rc1
- Update uv.lock

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

* .NET: Fixing issue where OpenTelemetry span is never exported in .NET in-process workflow execution (#4196)

* 1. Add reproduction test for issue #4155: workflow.run Activity never stopped in streaming OffThread path

The WorkflowRunActivity_IsStopped_Streaming_OffThread test demonstrates that
the workflow.run OpenTelemetry Activity created in StreamingRunEventStream.RunLoopAsync
is started but never stopped when using the OffThread/Default streaming execution.
The background run loop keeps running after event consumption completes, so the
using Activity? declaration never disposes until explicit StopAsync() is called.

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

2. Fix workflow.run Activity never stopped in streaming OffThread execution (#4155)

The workflow.run OpenTelemetry Activity in StreamingRunEventStream.RunLoopAsync
was scoped to the method lifetime via 'using'. Since the run loop only exits on
cancellation, the Activity was never stopped/exported until explicit disposal.

Fix: Remove 'using' and explicitly dispose the Activity when the workflow reaches
Idle status (all supersteps complete). A safety-net disposal in the finally block
handles cancellation and error paths.

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

* Add root-level workflow.session activity spanning run loop lifetime\n\nImplements two-level telemetry hierarchy per PR feedback from lokitoth:\n- workflow.session: spans the entire run loop / stream lifetime\n- workflow_invoke: per input-to-halt cycle, nested within the session\n\nThis ensures the session activity stays open across multiple turns,\nwhile individual run activities are created and disposed per cycle.\n\nAlso fixes linkedSource CancellationTokenSource disposal leak in\nStreamingRunEventStream (added using declaration)."

* Address Copilot review: fix Activity/CTS disposal, rename activity, add error tag\n\n1. LockstepRunEventStream: Remove 'using' from Activity in async iterator\n   and manually dispose in finally block (fixes #4155 pattern). Also dispose\n   linkedSource CTS in finally to prevent leak.\n2. Tags.cs: Add ErrorMessage (\"error.message\") tag for runtime errors,\n   distinct from BuildErrorMessage (\"build.error.message\").\n3. ActivityNames: Rename WorkflowRun from \"workflow_invoke\" to \"workflow.run\"\n   for cross-language consistency.\n4. WorkflowTelemetryContext: Fix XML doc to say \"outer/parent span\" instead\n   of \"root-level span\".\n5. ObservabilityTests: Assert WorkflowSession absence when DisableWorkflowRun\n   is true.\n6. WorkflowRunActivityStopTests: Fix streaming test race by disposing\n   StreamingRun before asserting activities are stopped.\n7. StreamingRunEventStream/LockstepRunEventStream: Use Tags.ErrorMessage\n   instead of Tags.BuildErrorMessage for runtime error events."

* Review fixes: revert workflow_invoke rename, use 'using' for linkedSource, move SessionStarted earlier\n\n- Revert ActivityNames.WorkflowRun back to \"workflow_invoke\" (OTEL semantic convention contract)\n- Use 'using' declaration for linkedSource CTS in LockstepRunEventStream (no timing sensitivity)\n- Move SessionStarted event before WaitForInputAsync in StreamingRunEventStream to match Lockstep behavior"

* Improve naming and comments in WorkflowRunActivityStopTests"

* Prevent session Activity.Current leak in lockstep mode, add nesting test

Save and restore Activity.Current in LockstepRunEventStream.Start() so the
session activity doesn't leak into caller code via AsyncLocal. Re-establish
Activity.Current = sessionActivity before creating the run activity in
TakeEventStreamAsync to preserve parent-child nesting.

Add test verifying app activities after RunAsync are not parented under the
session, and that the workflow_invoke activity nests under the session."

* Fix stale XML doc: WorkflowRun -> WorkflowInvoke in ObservabilityTests

---------

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

* Python / .NET Samples - Restructure and Improve Samples (Feature Branc… (#4092)

* Python: .NET Samples - Restructure and Improve Samples (Feature Branch) (#4091)

* Moved by agent (#4094)

* Fix readme links

* .NET Samples - Create `04-hosting` learning path step (#4098)

* Agent move

* Agent reorderd

* Remove A2A section from README 

Removed A2A section from the Getting Started README.

* Agent fixed links

* Fix broken sample links in durable-agents README (#4101)

* Initial plan

* Fix broken internal links in documentation

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Revert template link changes; keep only durable-agents README fix

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

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

* .NET Samples - Create `03-workflows` learning path step (#4102)

* Fix solution project path

* Python: Fix broken markdown links to repo resources (outside /docs) (#4105)

* Initial plan

* Fix broken markdown links to repo resources

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Update README to rename .NET Workflows Samples section

---------

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

* .NET Samples - Create `02-agents` learning path step (#4107)

* .NET: Fix broken relative link in GroupChatToolApproval README (#4108)

* Initial plan

* Fix broken link in GroupChatToolApproval README

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

---------

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

* Update labeler configuration for workflow samples

* .NET - Reorder Agents samples to start from Step01 instead of Step04 (#4110)

* Fix solution

* Resolve new sample paths

* Move new AgentSkills and AgentWithMemory_Step04 samples

* Fix link

* Fix readme path

* fix: update stale dotnet/samples/Durable path reference in AGENTS.md

Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>

* Moved new sample

* Update solution

* Resolve merge (new sample)

* Sync to new sample - FoundryAgents_Step21_BingCustomSearch

* Updated README

* .NET Samples - Configuration Naming Update (#4149)

* .NET: Restore AzureFunctions index parity with ConsoleApps under DurableAgents samples (#4221)

* Clean-up `05_host_your_agent`

* Config setting consistency

* Refine samples

* AGENTS.md

* Move new samples

* Re-order samples

* Move new project and fixup solution

* Fixup model config

* Fix up new UT project

---------

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

* Python: Fix Bedrock embedding test stub missing meta attribute (#4287)

* Fix Bedrock embedding test stub missing meta attribute

* Increase test coverage so gate passes

* Python: (ag-ui): fix approval payloads being re-processed on subsequent conversation turns (#4232)

* Fix ag-ui tool call issue

* Safe json fix

* Python: Update workflow orchestration samples to use AzureOpenAIResponsesClient (#4285)

* Update workflow orchestration samples to use AzureOpenAIResponsesClient

* Fix broken link

* Move scripts to scripts folder

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rishabh Chawla <rishabhchawla1995@gmail.com>
Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>

* Fix encoding (#4309)

* Disable Parallelization for WorkflowRunActivityStopTests (#4313)

* Revert parallel disable (#4324)

* .NET: Disable flakey Workflow Observability tests (#4416)

* Disable flakey OffThread test

* Disable additional OffThread test

* Disable a further test

* Disable all observability tests

---------

Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Chris <66376200+crickman@users.noreply.github.com>
Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Rishabh Chawla <rishabhchawla1995@gmail.com>
Co-authored-by: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Ben Thomas <ben.thomas@microsoft.com>
Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-03-05 14:14:33 +00:00
56bba795cb .NET: Add foundry extension samples for python and dotnet (#4359)
* Add foundry extension samples for python and dotnet

* Align foundry extension samples with existing hosted agent patterns

- Fix Python multiagent indentation bug (from_agent_framework ran in both modes)
- Remove hardcoded personal endpoint from appsettings.Development.json
- Rename .NET folders/projects to PascalCase (FoundryMultiAgent, FoundrySingleAgent)
- Upgrade .NET multiagent from net9.0 to net10.0
- Add ManagePackageVersionsCentrally=false and analyzer blocks to .csproj files
- Replace wildcard package versions with fixed versions
- Use alpine Docker images and standard build pattern
- Align agent.yaml structure (template nesting, displayName, resources, authors)
- Convert .NET multiagent from namespace/class to top-level statements
- Add run-requests.http for multiagent sample
- Fix Python requirements.txt (remove dev deps, add agent-framework)
- Add proper copyright headers

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

* Align foundry samples: fix builds, upgrade AgentServer to beta.8

- Fix TargetFrameworks (plural) to override inherited net472 from Directory.Build.props
- Upgrade Azure.AI.AgentServer.AgentFramework to 1.0.0-beta.8 (latest)
- Bump OpenTelemetry packages to 1.12.0 (required by beta.8)
- Fix Roslynator/format errors (imports ordering, BOM, sealed record, target-typed new)
- Verified with docker dotnet format (matching CI pipeline)

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

* Refactor hosted samples to use AIProjectClient.CreateAIAgentAsync

Replace PersistentAgentsClient and manual AzureOpenAIClient setup with
AIProjectClient.CreateAIAgentAsync() from Microsoft.Agents.AI.AzureAI.

- FoundryMultiAgent: Remove Azure.AI.Agents.Persistent, use CreateAIAgentAsync
  for Writer and Reviewer agents with cleanup in finally block
- FoundrySingleAgent: Remove manual GetConnection/AzureOpenAIClient chain,
  use CreateAIAgentAsync with hotel search tool
- Update csproj: add Microsoft.Agents.AI.AzureAI, remove unused packages

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

* Update READMEs to reflect AIProjectClient.CreateAIAgentAsync usage

- Reference Microsoft.Agents.AI.AzureAI and Microsoft.Agents.AI.Workflows packages
- Add Azure AI Developer role requirement for agents/write data action
- Replace PersistentAgentsClient references

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

* Add HostedAgents READMEs and Foundry samples to solution

- Create dotnet/samples/05-end-to-end/HostedAgents/README.md with sample index
- Create python/samples/05-end-to-end/hosted_agents/README.md with sample index
- Add FoundryMultiAgent and FoundrySingleAgent to agent-framework-dotnet.slnx

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

* Fix Python linting: reorder imports before load_dotenv, remove trailing whitespace

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

* Update uv.lock to match latest package versions

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

* Fix trailing whitespace in foundry_single_agent agent.yaml

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

* Exclude dotnet.microsoft.com from link checker

This domain intermittently times out in CI, causing flaky markdown
link check failures unrelated to PR changes.

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

* Align env vars to AZURE_AI_PROJECT_ENDPOINT and default model to gpt-4o-mini

Addresses PR review feedback:
- Rename PROJECT_ENDPOINT to AZURE_AI_PROJECT_ENDPOINT across all
  Foundry samples (dotnet + python) to match existing samples
- Change default model from gpt-4.1-mini to gpt-4o-mini consistently

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

* Skip flaky test CreatesWorkflowEndToEndActivities_WithCorrectName_DefaultAsync

Tracked in #4398

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

* Remove Python foundry samples from PR scope

Python hosted agent samples need further alignment with the azure-ai
package conventions. Removing from this PR to ship .NET samples first.

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

* Narrow linkspector exclusion to dotnet.microsoft.com/download only

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

---------

Co-authored-by: Leo Yao <leoyao@Leos-MacBook-Pro.local>
Co-authored-by: Roger Barreto <19890735+rogerbarreto@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 11:43:24 +00:00
westeyandGitHub 6dc65dbaa1 .NET: Increase credential timeout for Integration Tests (#4472)
* Increase credential timeout for Integration Tests

* Fix format error.

* Update further tests

* Fix comment

* Rename credentials file and class.

* Fix broken reference.
2026-03-05 10:32:45 +00:00
d02051dbb6 Python: Add propagate_session to as_tool() for session sharing in agent-as-tool scenarios (#4439)
* Python: Add propagate_session parameter to as_tool() for session sharing

Add opt-in session propagation in agent-as-tool scenarios. When
propagate_session=True, the parent agent's AgentSession is forwarded
to the sub-agent's run() call, allowing both agents to share session
state (history, metadata, session_id).

- Add propagate_session parameter to BaseAgent.as_tool() (default False)
- Include session in additional_function_arguments so it flows to tools
- Add 3 tests for propagation on/off and shared state verification
- Add sample showing session propagation with observability middleware

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

* Clarify propagate_session docstring per review feedback

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-04 23:14:17 +00:00
23644ac6a7 .NET: bug fix for duplicate output on GitHubCopilotAgent (#3981)
* bug fix for duplicate output on GitHubCopilotAgent

* Add Test code for bug fix of duplicate output on GitHubCopilotAgenttT

* update Test code for bug fix of duplicate output on GitHubCopilotAgenttT

* update Test for duplicate output of GitHubCopilotAgent

---------

Co-authored-by: Mark Wallace <127216156+markwallace-microsoft@users.noreply.github.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-03-04 21:40:34 +00:00
Shyju KrishnankuttyandCopilot 931ec6b2d6 Align DurableTask.Client.AzureManaged to 1.19.1
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 20:23:11 -08:00
Shyju KrishnankuttyandCopilot f59eba4926 Fix GetExecutorName to handle agent names with underscores
Split on last underscore instead of first, and validate that the
suffix is a 32-char hex string (sanitized GUID) before stripping it.
This prevents truncation of agent names like 'my_agent' when the
executor ID is 'my_agent_<guid>'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 19:57:43 -08:00
Shyju KrishnankuttyandCopilot 41d5c6ea47 Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 19:50:16 -08:00
Shyju KrishnankuttyandCopilot ae6b23c8d8 Fix dotnet format issues: add UTF-8 BOM and remove unused using
- Add UTF-8 BOM to 20 .cs files across DurableTask, AzureFunctions,
  unit tests, and workflow samples
- Remove unnecessary using directive in 07_SubWorkflows/Executors.cs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 17:46:14 -08:00
Shyju KrishnankuttyandGitHub 9213645141 Merge branch 'main' into feat/durable_task 2026-03-03 16:54:01 -08:00
Shyju KrishnankuttyandCopilot 3c586b25ca Fix build errors: remove duplicate base class members, update renamed APIs
- Remove duplicate OutputLog, WriteInputAsync, CreateTestTimeoutCts, etc. from
  ConsoleAppSamplesValidation (already in SamplesValidationBase)
- Update AddFanInEdge -> AddFanInBarrierEdge in workflow samples
- Update GetNewSessionAsync -> CreateSessionAsync in workflow samples
- Update SourceId -> ExecutorId (obsolete) in workflow samples

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 14:59:02 -08:00
Shyju KrishnankuttyandCopilot cd8344dbc1 Move durable workflow samples to 04-hosting/DurableWorkflows
Aligns with main branch sample reorganization where durable samples
live under 04-hosting/ (alongside DurableAgents/).

- Move samples/Durable/Workflow/ -> samples/04-hosting/DurableWorkflows/
- Add Directory.Build.props matching DurableAgents pattern
- Update slnx project paths
- Update integration test sample paths
- Update README cd paths and cross-references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 13:56:53 -08:00
Shyju KrishnankuttyandCopilot 123cd74aeb Fix build errors from main merge: Throw conflict, ExecuteAsync rename, GetNewSessionAsync rename
- Remove InjectSharedThrow from DurableTask csproj (uses Workflows' internal Throw via InternalsVisibleTo)
- Update ExecuteAsync -> ExecuteCoreAsync with WorkflowTelemetryContext.Disabled
- Update GetNewSessionAsync -> CreateSessionAsync

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 13:05:21 -08:00
Shyju KrishnankuttyandCopilot cd3929c8bf Fix broken markdown links in durable workflow sample READMEs
- Create Workflow/README.md with environment setup docs
- Fix ../README.md -> ../../README.md in ConsoleApps 01, 02, 03, 08
- Fix SubWorkflows relative path (3 levels -> 4 levels up)
- Fix dead Durable Task Scheduler URL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 13:00:50 -08:00
Shyju KrishnankuttyandCopilot f8eebfea8d Bump Microsoft.DurableTask.Worker to 1.19.1 to fix version downgrade
Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.13.1 requires
Microsoft.DurableTask.Worker >= 1.19.1 via its transitive dependency on
Microsoft.DurableTask.Worker.Grpc 1.19.1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:57:19 -08:00
Shyju KrishnankuttyandCopilot 154c44aaab Merge main into feat/durable_task and resolve conflicts
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:52:22 -08:00
Shyju KrishnankuttyandCopilot bb96bf6df5 Add changelog entries for durable workflow support (#4436)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-03 11:43:17 -08:00
Shyju KrishnankuttyandGitHub ad51aee47b .NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358)
* Add Azure Functions HITL workflow sample

Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop
workflow support with HTTP endpoints for status checking and approval responses.

The sample includes:
- ExpenseReimbursement workflow with RequestPort for manager approval
- Custom HTTP endpoint to check workflow status and pending approvals
- Custom HTTP endpoint to send approval responses via RaiseEventAsync
- demo.http file with step-by-step interaction examples

* PR feedback fixes

* Minor comment cleanup

* Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts      need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup

* fix  for PR feedback

* PR feedback updates

* Improvements to samples

* Improvements to README

* Update samples to use parallel request ports.

* Unit tests

* Introduce local variables to improve readability of Workflows.Workflows access patter

* Use GitHub-style callouts and add PowerShell command variants in HITL sample README
2026-03-03 11:19:14 -08:00
Shyju KrishnankuttyandGitHub 2988568cab .NET: [Feature Branch] Add nested sub-workflow support for durable workflows (#4190)
* .NET: [Feature Branch] Add nested sub-workflow support for durable workflows

* fix readme path

* Switch Orchestration output from string to DurableWorkflowResult.

* PR feedback fixes

* Minor cleanup based on PR feedback.
2026-02-25 14:55:15 -08:00
3256baa8b6 .NET: [Feature Branch] Adding support for events & shared state in durable workflows (#4020)
* Adding support for events & shared state in durable workflows.

* PR feedback fixes

* PR feedback fixes.

* Add YieldOutputAsync calls to 05_WorkflowEvents sample executors

The integration test asserts that WorkflowOutputEvent is found in the
stream, but the sample executors only used AddEventAsync for custom
events and never called YieldOutputAsync. Since WorkflowOutputEvent is
only emitted via explicit YieldOutputAsync calls, the assertion would
fail. Added YieldOutputAsync to each executor to match the test
expectation and demonstrate the API in the sample.

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

* Fix deserialization to use shared serializer options.

* PR feedback updates.

* Sample cleanup

* PR feedback fixes

* Addressing PR review feedback for DurableStreamingWorkflowRun

   - Use -1 instead of 0 for taskId in TaskFailedException when task ID is not relevant.
   - Add [NotNullWhen(true)] to TryParseWorkflowResult out parameter following .NET TryXXX conventions.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-20 16:49:47 -08:00
Shyju KrishnankuttyandGitHub b62b1f2191 .NET: [Feature Branch] Add Azure Functions hosting support for durable workflows (#3935)
* Adding azure functions workflow support.

* - PR feedback fixes.
- Add example to demonstrate complex Object as payload.

* rename instanceId to runId.

* Use custom ITaskOrchestrator to run orchestrator function.
2026-02-14 16:19:28 -08:00
Shyju KrishnankuttyandGitHub e8d0bd9051 .NET: [Feature Branch] Add basic durable workflow support (#3648)
* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.
2026-02-06 16:02:42 -08:00
489 changed files with 23802 additions and 4920 deletions
+1
View File
@@ -20,6 +20,7 @@ ignorePatterns:
- pattern: "https://your-resource.openai.azure.com/"
- pattern: "http://host.docker.internal"
- pattern: "https://openai.github.io/openai-agents-js/openai/agents/classes/"
- pattern: "https:\/\/dotnet.microsoft.com\/download"
# excludedDirs:
# Folders which include links to localhost, since it's not ignored with regular expressions
baseUrl: https://github.com/microsoft/agent-framework/
+18
View File
@@ -8,6 +8,10 @@ inputs:
os:
description: The operating system to set up
required: true
exclude-packages:
description: Space-separated list of packages to exclude from uv sync
required: false
default: ''
runs:
using: "composite"
@@ -19,6 +23,20 @@ runs:
enable-cache: true
cache-suffix: ${{ inputs.os }}-${{ inputs.python-version }}
cache-dependency-glob: "**/uv.lock"
- name: Exclude incompatible workspace packages
if: ${{ inputs.exclude-packages != '' }}
shell: bash
run: |
for pkg in ${{ inputs.exclude-packages }}; do
for f in python/packages/*/pyproject.toml; do
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
done
- name: Install the project
shell: bash
run: |
+109 -48
View File
@@ -59,20 +59,20 @@ jobs:
if: steps.filter.outputs.dotnet != 'true'
run: echo "NOT dotnet file"
dotnet-build-and-test:
# Build the full solution (including samples) on all TFMs. No tests.
dotnet-build:
needs: paths-filter
if: needs.paths-filter.outputs.dotnetChanges == 'true'
strategy:
fail-fast: false
matrix:
include:
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release }
- { targetFramework: "net9.0", os: "windows-latest", configuration: Debug }
- { targetFramework: "net8.0", os: "ubuntu-latest", configuration: Release }
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
- { targetFramework: "net472", os: "windows-latest", configuration: Release }
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
with:
@@ -84,16 +84,6 @@ jobs:
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
with:
@@ -140,25 +130,98 @@ jobs:
popd
rm -rf "$TEMP_DIR"
- name: Run Unit Tests
shell: bash
run: |
export UT_PROJECTS=$(find ./dotnet -type f -name "*.UnitTests.csproj" | tr '\n' ' ')
for project in $UT_PROJECTS; do
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
# Build src+tests only (no samples) for a single TFM and run tests.
dotnet-test:
needs: paths-filter
if: needs.paths-filter.outputs.dotnetChanges == 'true'
strategy:
fail-fast: false
matrix:
include:
- { targetFramework: "net10.0", os: "ubuntu-latest", configuration: Release, integration-tests: true, environment: "integration" }
- { targetFramework: "net472", os: "windows-latest", configuration: Release, integration-tests: true, environment: "integration" }
# Check if the project supports the target framework
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
if [[ "${{ matrix.targetFramework }}" == "${{ env.COVERAGE_FRAMEWORK }}" ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --collect:"XPlat Code Coverage" --results-directory:"TestResults/Coverage/" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.ExcludeByAttribute=GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute
else
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx
fi
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done
runs-on: ${{ matrix.os }}
environment: ${{ matrix.environment }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
.
.github
dotnet
python
workflow-samples
# Start Cosmos DB Emulator for all integration tests and only for unit tests when CosmosDB changes happened)
- name: Start Azure Cosmos DB Emulator
if: ${{ runner.os == 'Windows' && (needs.paths-filter.outputs.cosmosDbChanges == 'true' || (github.event_name != 'pull_request' && matrix.integration-tests)) }}
shell: pwsh
run: |
Write-Host "Launching Azure Cosmos DB Emulator"
Import-Module "$env:ProgramFiles\Azure Cosmos DB Emulator\PSModules\Microsoft.Azure.CosmosDB.Emulator"
Start-CosmosDbEmulator -NoUI -Key "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Generate test solution (no samples)
shell: pwsh
run: |
./dotnet/eng/scripts/New-FilteredSolution.ps1 `
-Solution dotnet/agent-framework-dotnet.slnx `
-TargetFramework ${{ matrix.targetFramework }} `
-Configuration ${{ matrix.configuration }} `
-ExcludeSamples `
-OutputPath dotnet/filtered.slnx `
-Verbose
- name: Build src and tests
shell: bash
run: dotnet build dotnet/filtered.slnx -c ${{ matrix.configuration }} -f ${{ matrix.targetFramework }} --warnaserror
- name: Generate test-type filtered solutions
shell: pwsh
run: |
$commonArgs = @{
Solution = "dotnet/filtered.slnx"
TargetFramework = "${{ matrix.targetFramework }}"
Configuration = "${{ matrix.configuration }}"
Verbose = $true
}
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*UnitTests*" `
-OutputPath dotnet/filtered-unit.slnx
./dotnet/eng/scripts/New-FilteredSolution.ps1 @commonArgs `
-TestProjectNameFilter "*IntegrationTests*" `
-OutputPath dotnet/filtered-integration.slnx
- name: Run Unit Tests
shell: pwsh
working-directory: dotnet
run: |
$coverageSettings = Join-Path $PWD "tests/coverage.runsettings"
$coverageArgs = @()
if ("${{ matrix.targetFramework }}" -eq "${{ env.COVERAGE_FRAMEWORK }}") {
$coverageArgs = @(
"--coverage",
"--coverage-output-format", "cobertura",
"--coverage-settings", $coverageSettings,
"--results-directory", "../TestResults/Coverage/"
)
}
dotnet test --solution ./filtered-unit.slnx `
-f ${{ matrix.targetFramework }} `
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--ignore-exit-code 8 `
@coverageArgs
env:
# Cosmos DB Emulator connection settings
COSMOSDB_ENDPOINT: https://localhost:8081
@@ -185,21 +248,19 @@ jobs:
id: azure-functions-setup
- name: Run Integration Tests
shell: bash
shell: pwsh
working-directory: dotnet
if: github.event_name != 'pull_request' && matrix.integration-tests
run: |
export INTEGRATION_TEST_PROJECTS=$(find ./dotnet -type f -name "*IntegrationTests.csproj" | tr '\n' ' ')
for project in $INTEGRATION_TEST_PROJECTS; do
# Query the project's target frameworks using MSBuild with the current configuration
target_frameworks=$(dotnet msbuild $project -getProperty:TargetFrameworks -p:Configuration=${{ matrix.configuration }} -nologo 2>/dev/null | tr -d '\r')
# Check if the project supports the target framework
if [[ "$target_frameworks" == *"${{ matrix.targetFramework }}"* ]]; then
dotnet test -f ${{ matrix.targetFramework }} -c ${{ matrix.configuration }} $project --no-build -v Normal --logger trx --filter "Category!=IntegrationDisabled"
else
echo "Skipping $project - does not support target framework ${{ matrix.targetFramework }} (supports: $target_frameworks)"
fi
done
dotnet test --solution ./filtered-integration.slnx `
-f ${{ matrix.targetFramework }} `
-c ${{ matrix.configuration }} `
--no-build -v Normal `
--report-xunit-trx `
--ignore-exit-code 8 `
--filter-not-trait "Category=IntegrationDisabled" `
--parallel-algorithm aggressive `
--max-threads 2.0x
env:
# Cosmos DB Emulator connection settings
COSMOSDB_ENDPOINT: https://localhost:8081
@@ -222,7 +283,7 @@ jobs:
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
with:
reports: "./TestResults/Coverage/**/coverage.cobertura.xml"
reports: "./TestResults/Coverage/**/*.cobertura.xml"
targetdir: "./TestResults/Reports"
reporttypes: "HtmlInline;JsonSummary"
@@ -236,13 +297,13 @@ jobs:
- name: Check coverage
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
shell: pwsh
run: .github/workflows/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
run: ./dotnet/eng/scripts/dotnet-check-coverage.ps1 -JsonReportPath "TestResults/Reports/Summary.json" -CoverageThreshold $env:COVERAGE_THRESHOLD
# This final job is required to satisfy the merge queue. It must only run (or succeed) if no tests failed
dotnet-build-and-test-check:
if: always()
runs-on: ubuntu-latest
needs: [dotnet-build-and-test]
needs: [dotnet-build, dotnet-test]
steps:
- name: Get Date
shell: bash
+4 -4
View File
@@ -18,7 +18,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -55,7 +55,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -84,7 +84,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -117,7 +117,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10"]
python-version: ["3.11"]
runs-on: ubuntu-latest
continue-on-error: true
defaults:
@@ -170,7 +170,7 @@ jobs:
environment: integration
timeout-minutes: 60
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
+1
View File
@@ -67,6 +67,7 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
+1 -1
View File
@@ -288,7 +288,7 @@ jobs:
runs-on: ubuntu-latest
environment: integration
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
run:
working-directory: python
env:
UV_PYTHON: "3.10"
UV_PYTHON: "3.11"
steps:
- uses: actions/checkout@v6
# Save the PR number to a file since the workflow_run event
+2 -1
View File
@@ -34,12 +34,13 @@ jobs:
with:
python-version: ${{ matrix.python-version }}
os: ${{ runner.os }}
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
env:
# Configure a constant location for the uv cache
UV_CACHE_DIR: /tmp/.uv-cache
# Unit tests
- name: Run all tests
run: uv run poe all-tests
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
working-directory: ./python
# Surface failing tests
+50 -5
View File
@@ -17,14 +17,17 @@ dotnet format # Auto-fix formatting for all projects
# Build/test/format a specific project (preferred for isolated/internal changes)
dotnet build src/Microsoft.Agents.AI.<Package> --tl:off
dotnet test tests/Microsoft.Agents.AI.<Package>.UnitTests
dotnet test --project tests/Microsoft.Agents.AI.<Package>.UnitTests
dotnet format src/Microsoft.Agents.AI.<Package>
# Run a single test
dotnet test --filter "FullyQualifiedName~Namespace.TestClassName.TestMethodName"
# Replace the filter values with the appropriate assembly, namespace, class, and method names for the test you want to run and use * as a wildcard elsewhere, e.g. "/*/*/HttpClientTests/GetAsync_ReturnsSuccessStatusCode"
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for some projects
dotnet test --filter-query "/<assemblyFilter>/<namespaceFilter>/<classFilter>/<methodFilter>" --ignore-exit-code 8
# Run unit tests only
dotnet test --filter FullyQualifiedName\~UnitTests
# Use `--ignore-exit-code 8` to avoid failing the build when no tests are found for integration test projects
dotnet test --filter-query "/*UnitTests*/*/*/*" --ignore-exit-code 8
```
Use `--tl:off` when building to avoid flickering when running commands in the agent.
@@ -56,7 +59,7 @@ Example: Running tests for a single project using .NET 10.
```bash
# From dotnet/ directory
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0
```
Example: Running a single test in a specific project using .NET 10.
@@ -64,7 +67,7 @@ Provide the full namespace, class name, and method name for the test you want to
```bash
# From dotnet/ directory
dotnet test ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter "FullyQualifiedName~Microsoft.Agents.AI.Abstractions.UnitTests.AgentRunOptionsTests.CloningConstructorCopiesProperties"
dotnet test --project ./tests/Microsoft.Agents.AI.Abstractions.UnitTests -f net10.0 --filter-query "/*/Microsoft.Agents.AI.Abstractions.UnitTests/AgentRunOptionsTests/CloningConstructorCopiesProperties"
```
### Multi-target framework tip
@@ -83,3 +86,45 @@ Just remember to run `dotnet restore` after pulling changes, making changes to p
Unit tests target both .NET Framework as well as .NET Core. When running on Linux, only the .NET Core tests can be run, as .NET Framework is not supported on Linux.
To run only the .NET Core tests, use the `-f net10.0` option with `dotnet test`.
### Microsoft Testing Platform (MTP)
Tests use the [Microsoft Testing Platform](https://learn.microsoft.com/dotnet/core/testing/unit-testing-platform-intro) via xUnit v3. Key differences from the legacy VSTest runner:
- **`dotnet test` requires `--project`** to specify a test project directly (positional arguments are no longer supported).
- **Test output** uses the MTP format (e.g., `[✓112/x0/↓0]` progress and `Test run summary: Passed!`).
- **TRX reports** use `--report-xunit-trx` instead of `--logger trx`.
- **Code coverage** uses `Microsoft.Testing.Extensions.CodeCoverage` with `--coverage --coverage-output-format cobertura`.
- **Running a test project directly** is supported via `dotnet run --project <test-project>`. This bypasses the `dotnet test` infrastructure and runs the test executable directly with the MTP command line.
- **Running tests across the solution** with a filter may cause some projects to match zero tests, which MTP treats as a failure (exit code 8). Use `--ignore-exit-code 8` to suppress this:
```bash
# Run all unit tests across the solution, ignoring projects with no matching tests
dotnet test --solution ./agent-framework-dotnet.slnx --no-build -f net10.0 --ignore-exit-code 8
```
- **Running tests with `--solution` for a specific TFM** requires all projects in the solution to support that TFM. Not all projects target every framework (e.g., some are `net10.0`-only). Use `./dotnet/eng/scripts/New-FilteredSolution.ps1` to generate a filtered solution:
```powershell
# Generate a filtered solution for net472 and run tests
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
dotnet test --solution $filtered --no-build -f net472 --ignore-exit-code 8
# Exclude samples and keep only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -ExcludeSamples -TestProjectNameFilter "*UnitTests*" -OutputPath dotnet/filtered-unit.slnx
```
```bash
# Run tests via dotnet test (uses MTP under the hood)
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
# Run tests with code coverage (Cobertura format)
dotnet test --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 --coverage --coverage-output-format cobertura --coverage-settings ./tests/coverage.runsettings
# Run tests directly via dotnet run (MTP native command line)
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0
# Show MTP command line help
dotnet run --project ./tests/Microsoft.Agents.AI.UnitTests -f net10.0 -- -?
```
+12 -14
View File
@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<!-- Aspire.* -->
<PackageVersion Include="Anthropic" Version="12.3.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.4.1" />
<PackageVersion Include="Anthropic" Version="12.8.0" />
<PackageVersion Include="Anthropic.Foundry" Version="0.4.2" />
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
@@ -118,15 +118,15 @@
<PackageVersion Include="Microsoft.Agents.ObjectModel.PowerFx" Version="2026.2.4.1" />
<PackageVersion Include="Microsoft.PowerFx.Interpreter" Version="1.8.1" />
<!-- Durable Task -->
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.18.0" />
<PackageVersion Include="Microsoft.DurableTask.Client" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Client.AzureManaged" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker" Version="1.22.0" />
<PackageVersion Include="Microsoft.DurableTask.Worker.AzureManaged" Version="1.22.0" />
<!-- Azure Functions -->
<PackageVersion Include="Microsoft.Azure.Functions.Worker" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.11.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" Version="1.16.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.5.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Mcp" Version="1.0.0" />
@@ -140,12 +140,10 @@
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Condition="'$(TargetFramework)' == 'net10.0'" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
<PackageVersion Include="Moq" Version="[4.18.4]" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.abstractions" Version="2.0.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xretry" Version="1.9.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xRetry.v3" Version="1.0.0-rc3" />
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.4.1" />
<!-- Symbols -->
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<!-- Toolset -->
+34 -2
View File
@@ -60,6 +60,25 @@
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/DurableWorkflows/" />
<Folder Name="/Samples/04-hosting/DurableWorkflows/ConsoleApps/">
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/04-hosting/DurableWorkflows/AzureFunctions/">
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
</Folder>
<Folder Name="/Samples/02-agents/AGUI/">
<File Path="samples/02-agents/AGUI/README.md" />
</Folder>
@@ -103,6 +122,7 @@
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
@@ -284,8 +304,13 @@
</Folder>
<Folder Name="/Samples/05-end-to-end/HostedAgents/">
<Project Path="samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundryMultiAgent/FoundryMultiAgent.csproj" />
<Project Path="samples/05-end-to-end/HostedAgents/FoundrySingleAgent/FoundrySingleAgent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/AspNetAgentAuthorization/">
<File Path="samples/05-end-to-end/AspNetAgentAuthorization/docker-compose.yml" />
@@ -311,7 +336,6 @@
</Folder>
<Folder Name="/Solution Items/.github/workflows/">
<File Path="../.github/workflows/dotnet-build-and-test.yml" />
<File Path="../.github/workflows/dotnet-check-coverage.ps1" />
<File Path="../.github/workflows/dotnet-format.yml" />
</Folder>
<Folder Name="/Solution Items/demos/">
@@ -348,6 +372,10 @@
<File Path="eng/MSBuild/Shared.props" />
<File Path="eng/MSBuild/Shared.targets" />
</Folder>
<Folder Name="/Solution Items/eng/scripts/">
<File Path="eng/scripts/dotnet-check-coverage.ps1" />
<File Path="eng/scripts/New-FilteredSolution.ps1" />
</Folder>
<Folder Name="/Solution Items/nuget/">
<File Path="nuget/icon.png" />
<File Path="nuget/nuget-package.props" />
@@ -413,6 +441,10 @@
<File Path="src/Shared/IntegrationTests/OpenAIConfiguration.cs" />
<File Path="src/Shared/IntegrationTests/README.md" />
</Folder>
<Folder Name="/Solution Items/src/Shared/IntegrationTestsAzureCredentials/">
<File Path="src/Shared/IntegrationTestsAzureCredentials/README.md" />
<File Path="src/Shared/IntegrationTestsAzureCredentials/TestAzureCliCredentials.cs" />
</Folder>
<Folder Name="/Solution Items/src/Shared/Samples/">
<File Path="src/Shared/Samples/BaseSample.cs" />
<File Path="src/Shared/Samples/README.md" />
@@ -506,4 +538,4 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
</Solution>
+3
View File
@@ -8,6 +8,9 @@
<ItemGroup Condition="'$(InjectSharedIntegrationTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTests\*.cs" LinkBase="Shared\IntegrationTests" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedIntegrationTestAzureCredentialsCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\IntegrationTestsAzureCredentials\*.cs" LinkBase="Shared\IntegrationTestsAzureCredentials" />
</ItemGroup>
<ItemGroup Condition="'$(InjectSharedBuildTestCode)' == 'true'">
<Compile Include="$(MSBuildThisFileDirectory)\..\..\src\Shared\CodeTests\*.cs" LinkBase="Shared\CodeTests" />
</ItemGroup>
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env pwsh
# Copyright (c) Microsoft. All rights reserved.
<#
.SYNOPSIS
Generates a filtered .slnx solution file by removing projects that don't match the specified criteria.
.DESCRIPTION
Parses a .slnx solution file and applies one or more filters:
- Removes projects that don't support the specified target framework (via MSBuild query).
- Optionally removes all sample projects (under samples/).
- Optionally filters test projects by name pattern (e.g., only *UnitTests*).
Writes the filtered solution to the specified output path and prints the path.
.PARAMETER Solution
Path to the source .slnx solution file.
.PARAMETER TargetFramework
The target framework to filter by (e.g., net10.0, net472).
.PARAMETER Configuration
Optional MSBuild configuration used when querying TargetFrameworks. Defaults to Debug.
.PARAMETER TestProjectNameFilter
Optional wildcard pattern to filter test project names (e.g., *UnitTests*, *IntegrationTests*).
When specified, only test projects whose filename matches this pattern are kept.
.PARAMETER ExcludeSamples
When specified, removes all projects under the samples/ directory from the solution.
.PARAMETER OutputPath
Optional output path for the filtered .slnx file. If not specified, a temp file is created.
.EXAMPLE
# Generate a filtered solution and run tests
$filtered = ./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472
dotnet test --solution $filtered --no-build -f net472
.EXAMPLE
# Generate a solution with only unit test projects
./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net10.0 -TestProjectNameFilter "*UnitTests*" -OutputPath filtered-unit.slnx
.EXAMPLE
# Inline usage with dotnet test (PowerShell)
dotnet test --solution (./dotnet/eng/scripts/New-FilteredSolution.ps1 -Solution dotnet/agent-framework-dotnet.slnx -TargetFramework net472) --no-build -f net472
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Solution,
[Parameter(Mandatory)]
[string]$TargetFramework,
[string]$Configuration = "Debug",
[string]$TestProjectNameFilter,
[switch]$ExcludeSamples,
[string]$OutputPath
)
$ErrorActionPreference = "Stop"
# Resolve the solution path
$solutionPath = Resolve-Path $Solution
$solutionDir = Split-Path $solutionPath -Parent
if (-not $OutputPath) {
$OutputPath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "filtered-$(Split-Path $solutionPath -Leaf)")
}
# Parse the .slnx XML
[xml]$slnx = Get-Content $solutionPath -Raw
$removed = @()
$kept = @()
# Remove sample projects if requested
if ($ExcludeSamples) {
$sampleProjects = $slnx.SelectNodes("//Project[contains(@Path, 'samples/')]")
foreach ($proj in $sampleProjects) {
$projRelPath = $proj.GetAttribute("Path")
Write-Verbose "Removing (sample): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
}
Write-Host "Removed $($sampleProjects.Count) sample project(s)." -ForegroundColor Yellow
}
# Filter all remaining projects by target framework
$allProjects = $slnx.SelectNodes("//Project")
foreach ($proj in $allProjects) {
$projRelPath = $proj.GetAttribute("Path")
$projFullPath = Join-Path $solutionDir $projRelPath
$projFileName = Split-Path $projRelPath -Leaf
$isTestProject = $projRelPath -like "*tests/*"
# Filter test projects by name pattern if specified
if ($isTestProject -and $TestProjectNameFilter -and ($projFileName -notlike $TestProjectNameFilter)) {
Write-Verbose "Removing (name filter): $projRelPath"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
continue
}
if (-not (Test-Path $projFullPath)) {
Write-Verbose "Project not found, keeping in solution: $projRelPath"
$kept += $projRelPath
continue
}
# Query the project's target frameworks using MSBuild
$targetFrameworks = & dotnet msbuild $projFullPath -getProperty:TargetFrameworks -p:Configuration=$Configuration -nologo 2>$null
$targetFrameworks = $targetFrameworks.Trim()
if ($targetFrameworks -like "*$TargetFramework*") {
Write-Verbose "Keeping: $projRelPath (targets: $targetFrameworks)"
$kept += $projRelPath
}
else {
Write-Verbose "Removing: $projRelPath (targets: $targetFrameworks, missing: $TargetFramework)"
$removed += $projRelPath
$proj.ParentNode.RemoveChild($proj) | Out-Null
}
}
# Write the filtered solution
$slnx.Save($OutputPath)
# Report results to stderr so stdout is clean for piping
Write-Host "Filtered solution written to: $OutputPath" -ForegroundColor Green
if ($removed.Count -gt 0) {
Write-Host "Removed $($removed.Count) project(s):" -ForegroundColor Yellow
foreach ($r in $removed) {
Write-Host " - $r" -ForegroundColor Yellow
}
}
Write-Host "Kept $($kept.Count) project(s)." -ForegroundColor Green
# Output the path for piping
Write-Output $OutputPath
+3
View File
@@ -3,5 +3,8 @@
"version": "10.0.100",
"rollForward": "minor",
"allowPrerelease": false
},
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
namespace SampleApp;
/// <summary>
/// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state
/// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store
/// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector
/// store for relevant older messages and prepends them as a memory context message.
/// </summary>
/// <remarks>
/// Only non-system messages are counted towards the session state limit and overflow mechanism. System messages are always retained in session state and are not included in the vector store.
/// Function calls and function results are also dropped when truncation happens, both from in-memory state, and they are also not persisted to the vector store.
/// </remarks>
internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable
{
private readonly InMemoryChatHistoryProvider _chatHistoryProvider;
private readonly ChatHistoryMemoryProvider _memoryProvider;
private readonly TruncatingChatReducer _reducer;
private readonly string _contextPrompt;
private IReadOnlyList<string>? _stateKeys;
/// <summary>
/// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class.
/// </summary>
/// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param>
/// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param>
/// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param>
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
/// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param>
/// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param>
public BoundedChatHistoryProvider(
int maxSessionMessages,
VectorStore vectorStore,
string collectionName,
int vectorDimensions,
Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer,
string? contextPrompt = null)
{
if (maxSessionMessages < 0)
{
throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative.");
}
this._reducer = new TruncatingChatReducer(maxSessionMessages);
this._chatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
{
ChatReducer = this._reducer,
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
StorageInputRequestMessageFilter = msgs => msgs,
});
this._memoryProvider = new ChatHistoryMemoryProvider(
vectorStore,
collectionName,
vectorDimensions,
stateInitializer,
options: new ChatHistoryMemoryProviderOptions
{
SearchInputMessageFilter = msgs => msgs,
StorageInputRequestMessageFilter = msgs => msgs,
});
this._contextPrompt = contextPrompt
?? "The following are memories from earlier in this conversation. Use them to inform your responses:";
}
/// <inheritdoc />
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= this._chatHistoryProvider.StateKeys.Concat(this._memoryProvider.StateKeys).ToArray();
/// <inheritdoc />
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
// Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages).
var chatHistoryProviderInputContext = new InvokingContext(context.Agent, context.Session, []);
var allMessages = await this._chatHistoryProvider.InvokingAsync(chatHistoryProviderInputContext, cancellationToken).ConfigureAwait(false);
// Search the vector store for relevant older messages.
var aiContext = new AIContext { Messages = context.RequestMessages.ToList() };
var invokingContext = new AIContextProvider.InvokingContext(
context.Agent, context.Session, aiContext);
var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
// Extract only the messages added by the memory provider (stamped with AIContextProvider source type).
var memoryMessages = result.Messages?
.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider)
.ToList();
if (memoryMessages is { Count: > 0 })
{
var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t)));
if (!string.IsNullOrWhiteSpace(memoryText))
{
var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}");
return new[] { contextMessage }.Concat(allMessages);
}
}
return allMessages;
}
/// <inheritdoc />
protected override async ValueTask StoreChatHistoryAsync(
InvokedContext context,
CancellationToken cancellationToken = default)
{
// Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger)
// will automatically truncate to the configured maximum and expose any removed messages.
var innerContext = new InvokedContext(
context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!);
await this._chatHistoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false);
// Archive any messages that the reducer removed to the vector store.
if (this._reducer.RemovedMessages is { Count: > 0 })
{
var overflowContext = new AIContextProvider.InvokedContext(
context.Agent, context.Session, this._reducer.RemovedMessages, []);
await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public void Dispose()
{
this._memoryProvider.Dispose();
}
}
@@ -0,0 +1,79 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to create a bounded chat history provider that keeps a configurable number of
// recent messages in session state and automatically overflows older messages to a vector store.
// When the agent is invoked, it searches the vector store for relevant older messages and
// prepends them as a "memory" context message before the recent session history.
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using OpenAI.Chat;
using SampleApp;
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";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
var credential = new DefaultAzureCredential();
// Create a vector store to store overflow chat messages.
// For demonstration purposes, we are using an in-memory vector store.
// Replace this with a persistent vector store implementation for production scenarios.
VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions()
{
EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential)
.GetEmbeddingClient(embeddingDeploymentName)
.AsIEmbeddingGenerator()
});
var sessionId = Guid.NewGuid().ToString();
// Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state.
// It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a
// ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are
// automatically archived to the vector store and recalled via semantic search.
var boundedProvider = new BoundedChatHistoryProvider(
maxSessionMessages: 4,
vectorStore,
collectionName: "chathistory-overflow",
vectorDimensions: 3072,
session => new ChatHistoryMemoryProvider.State(
storageScope: new() { UserId = "UID1", SessionId = sessionId },
searchScope: new() { UserId = "UID1" }));
// Create the agent with the bounded chat history provider.
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential)
.GetChatClient(deploymentName)
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." },
Name = "Assistant",
ChatHistoryProvider = boundedProvider,
});
// Start a conversation. The first several exchanges will fill up the session state window.
AgentSession session = await agent.CreateSessionAsync();
Console.WriteLine("--- Filling the session window (4 messages max) ---\n");
Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session));
Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session));
// At this point the session state holds 4 messages (2 user + 2 assistant).
// The next exchange will push the oldest messages into the vector store.
Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n");
Console.WriteLine(await agent.RunAsync("What is the capital of France?", session));
// The oldest messages about favorite color have now been archived to the vector store.
// Ask the agent something that requires recalling the overflowed information.
Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n");
Console.WriteLine(await agent.RunAsync("What is my favorite color?", session));
@@ -0,0 +1,40 @@
# Bounded Chat History with Vector Store Overflow
This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps a bounded window of recent messages in session state and automatically overflows older messages to a vector store. When the agent is invoked, it searches the vector store for relevant older messages and prepends them as memory context.
## Concepts
- **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property.
- **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes:
- `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer)
- `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages
## Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
- An Azure OpenAI resource with:
- A chat deployment (e.g., `gpt-4o-mini`)
- An embedding deployment (e.g., `text-embedding-3-large`)
## Configuration
Set the following environment variables:
| Variable | Description | Default |
|---|---|---|
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` |
| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` |
## Running the Sample
```bash
dotnet run
```
## How it Works
1. The agent starts a conversation with a bounded session window of 4 non-system, non-function messages (i.e., user/assistant turns). System messages are always preserved, and function call/result messages are truncated and not preserved.
2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages.
3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`.
4. On subsequent invocations, the provider searches the vector store for relevant older messages and prepends them as memory context, allowing the agent to recall information from earlier in the conversation.
@@ -0,0 +1,65 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Extensions.AI;
namespace SampleApp;
/// <summary>
/// A truncating chat reducer that keeps the most recent messages up to a configured maximum,
/// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/>
/// so that a caller can archive them (e.g. to a vector store).
/// </summary>
internal sealed class TruncatingChatReducer : IChatReducer
{
private readonly int _maxMessages;
/// <summary>
/// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class.
/// </summary>
/// <param name="maxMessages">The maximum number of non-system messages to retain.</param>
public TruncatingChatReducer(int maxMessages)
{
this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages));
}
/// <summary>
/// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>.
/// </summary>
public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = [];
/// <inheritdoc />
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
{
_ = messages ?? throw new ArgumentNullException(nameof(messages));
ChatMessage? systemMessage = null;
Queue<ChatMessage> retained = new(capacity: this._maxMessages);
List<ChatMessage> removed = [];
foreach (var message in messages)
{
if (message.Role == ChatRole.System)
{
// Preserve the first system message outside the counting window.
systemMessage ??= message;
}
else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
{
if (retained.Count >= this._maxMessages)
{
removed.Add(retained.Dequeue());
}
retained.Enqueue(message);
}
}
this.RemovedMessages = removed;
IEnumerable<ChatMessage> result = systemMessage is not null
? new[] { systemMessage }.Concat(retained)
: retained;
return Task.FromResult(result);
}
}
@@ -8,5 +8,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,215 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SequentialWorkflow;
/// <summary>
/// Looks up an order by its ID and return an Order object.
/// </summary>
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message}'");
Console.ResetColor();
// Simulate database lookup with delay
await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken);
Order order = new(
Id: message,
OrderDate: DateTime.UtcNow.AddDays(-1),
IsCancelled: false,
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message}' for customer '{order.Customer.Name}'");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return order;
}
}
/// <summary>
/// Cancels an order.
/// </summary>
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
{
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
Console.ResetColor();
// Simulate a slow cancellation process (e.g., calling external payment system)
for (int i = 1; i <= 3; i++)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
Console.ResetColor();
}
Order cancelledOrder = message with { IsCancelled = true };
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return cancelledOrder;
}
}
/// <summary>
/// Sends a cancellation confirmation email to the customer.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
Console.ResetColor();
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(result);
}
}
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, Customer Customer);
internal sealed record Customer(string Name, string Email);
/// <summary>
/// Represents a batch cancellation request with multiple order IDs and a reason.
/// This demonstrates using a complex typed object as workflow input.
/// </summary>
#pragma warning disable CA1812 // Instantiated via JSON deserialization at runtime
internal sealed record BatchCancelRequest(string[] OrderIds, string Reason, bool NotifyCustomers);
#pragma warning restore CA1812
/// <summary>
/// Represents the result of processing a batch cancellation.
/// </summary>
internal sealed record BatchCancelResult(int TotalOrders, int CancelledCount, string Reason);
/// <summary>
/// Generates a status report for an order.
/// </summary>
internal sealed class StatusReport() : Executor<Order, string>("StatusReport")
{
public override ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] StatusReport: Generating report for order '{message.Id}'");
Console.ResetColor();
string status = message.IsCancelled ? "Cancelled" : "Active";
string result = $"Order {message.Id} for {message.Customer.Name}: Status={status}, Date={message.OrderDate:yyyy-MM-dd}";
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"│ [Activity] StatusReport: ✓ {result}");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(result);
}
}
/// <summary>
/// Processes a batch cancellation request. Accepts a complex <see cref="BatchCancelRequest"/> object
/// as input, demonstrating how workflows can receive structured JSON input.
/// </summary>
internal sealed class BatchCancelProcessor() : Executor<BatchCancelRequest, BatchCancelResult>("BatchCancelProcessor")
{
public override async ValueTask<BatchCancelResult> HandleAsync(
BatchCancelRequest message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Processing {message.OrderIds.Length} orders");
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Reason: {message.Reason}");
Console.WriteLine($"│ [Activity] BatchCancelProcessor: Notify customers: {message.NotifyCustomers}");
Console.ResetColor();
// Simulate processing each order
int cancelledCount = 0;
foreach (string orderId in message.OrderIds)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
cancelledCount++;
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Cancelled order '{orderId}'");
Console.ResetColor();
}
BatchCancelResult result = new(message.OrderIds.Length, cancelledCount, message.Reason);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"│ [Activity] BatchCancelProcessor: ✓ Batch complete: {cancelledCount}/{message.OrderIds.Length} cancelled");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return result;
}
}
/// <summary>
/// Generates a summary of the batch cancellation.
/// </summary>
internal sealed class BatchCancelSummary() : Executor<BatchCancelResult, string>("BatchCancelSummary")
{
public override ValueTask<string> HandleAsync(
BatchCancelResult message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [Activity] BatchCancelSummary: Generating summary");
Console.ResetColor();
string result = $"Batch cancellation complete: {message.CancelledCount}/{message.TotalOrders} orders cancelled. Reason: {message.Reason}";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"│ [Activity] BatchCancelSummary: ✓ {result}");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(result);
}
}
@@ -0,0 +1,52 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates three workflows that share executors.
// The CancelOrder workflow cancels an order and notifies the customer.
// The OrderStatus workflow looks up an order and generates a status report.
// The BatchCancelOrders workflow accepts a complex JSON input to cancel multiple orders.
// Both CancelOrder and OrderStatus reuse the same OrderLookup executor, demonstrating executor sharing.
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using SequentialWorkflow;
// Define executors for all workflows
OrderLookup orderLookup = new();
OrderCancel orderCancel = new();
SendEmail sendEmail = new();
StatusReport statusReport = new();
BatchCancelProcessor batchCancelProcessor = new();
BatchCancelSummary batchCancelSummary = new();
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
.WithName("CancelOrder")
.WithDescription("Cancel an order and notify the customer")
.AddEdge(orderLookup, orderCancel)
.AddEdge(orderCancel, sendEmail)
.Build();
// Build the OrderStatus workflow: OrderLookup -> StatusReport
// This workflow shares the OrderLookup executor with the CancelOrder workflow.
Workflow orderStatus = new WorkflowBuilder(orderLookup)
.WithName("OrderStatus")
.WithDescription("Look up an order and generate a status report")
.AddEdge(orderLookup, statusReport)
.Build();
// Build the BatchCancelOrders workflow: BatchCancelProcessor -> BatchCancelSummary
// This workflow demonstrates using a complex JSON object as the workflow input.
Workflow batchCancelOrders = new WorkflowBuilder(batchCancelProcessor)
.WithName("BatchCancelOrders")
.WithDescription("Cancel multiple orders in a batch using a complex JSON input")
.AddEdge(batchCancelProcessor, batchCancelSummary)
.Build();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(cancelOrder, orderStatus, batchCancelOrders))
.Build();
app.Run();
@@ -0,0 +1,100 @@
# Sequential Workflow Sample
This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that hosts durable workflows with sequential executor chains. It showcases two workflows that share a common executor, demonstrating executor reuse across workflows.
## Key Concepts Demonstrated
- Defining workflows with sequential executor chains using `WorkflowBuilder`
- Sharing executors across multiple workflows (the `OrderLookup` executor is used by both workflows)
- Registering workflows with the Function app using `ConfigureDurableWorkflows`
- Durable orchestration ensuring workflows survive process restarts and failures
- Starting workflows via HTTP requests
- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard
## Workflows
This sample defines two workflows:
1. **CancelOrder**: `OrderLookup``OrderCancel``SendEmail` — Looks up an order, cancels it, and sends a confirmation email.
2. **OrderStatus**: `OrderLookup``StatusReport` — Looks up an order and generates a status report.
Both workflows share the `OrderLookup` executor, which is registered only once by the framework.
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints.
You can use the `demo.http` file to trigger the workflows, or a command line tool like `curl` as shown below:
### Cancel an Order
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/CancelOrder/run \
-H "Content-Type: text/plain" \
-d "12345"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/CancelOrder/run `
-ContentType text/plain `
-Body "12345"
```
The response will confirm the workflow orchestration has started:
```text
Workflow orchestration started for CancelOrder. Orchestration runId: abc123def456
```
> **Tip:** You can provide a custom run ID by appending a `runId` query parameter:
>
> ```bash
> curl -X POST "http://localhost:7071/api/workflows/CancelOrder/run?runId=my-order-123" \
> -H "Content-Type: text/plain" \
> -d "12345"
> ```
>
> If not provided, a unique run ID is auto-generated.
In the function app logs, you will see the sequential execution of each executor:
```text
│ [Activity] OrderLookup: Starting lookup for order '12345'
│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
│ [Activity] OrderCancel: Starting cancellation for order '12345'
│ [Activity] OrderCancel: ✓ Order '12345' has been cancelled
│ [Activity] SendEmail: Sending email to 'jerry@example.com'...
│ [Activity] SendEmail: ✓ Email sent successfully!
```
### Get Order Status
```bash
curl -X POST http://localhost:7071/api/workflows/OrderStatus/run \
-H "Content-Type: text/plain" \
-d "12345"
```
The `OrderStatus` workflow reuses the same `OrderLookup` executor and then generates a status report:
```text
│ [Activity] OrderLookup: Starting lookup for order '12345'
│ [Activity] OrderLookup: Found order '12345' for customer 'Jerry'
│ [Activity] StatusReport: Generating report for order '12345'
│ [Activity] StatusReport: ✓ Order 12345 for Jerry: Status=Active, Date=2025-01-01
```
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
@@ -0,0 +1,26 @@
# Default endpoint address for local testing
@authority=http://localhost:7071
### Cancel an order
POST {{authority}}/api/workflows/CancelOrder/run
Content-Type: text/plain
12345
### Cancel an order with a custom run ID
POST {{authority}}/api/workflows/CancelOrder/run?runId=my-custom-id-123
Content-Type: text/plain
99999
### Get order status (shares OrderLookup executor with CancelOrder)
POST {{authority}}/api/workflows/OrderStatus/run
Content-Type: text/plain
12345
### Batch cancel orders with a complex JSON input
POST {{authority}}/api/workflows/BatchCancelOrders/run
Content-Type: application/json
{"orderIds": ["1001", "1002", "1003"], "reason": "Customer requested cancellation", "notifyCustomers": true}
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>SingleAgent</AssemblyName>
<RootNamespace>SingleAgent</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowConcurrency;
/// <summary>
/// Parses and validates the incoming question before sending to AI agents.
/// </summary>
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
{
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
string formattedQuestion = message.Trim();
if (!formattedQuestion.EndsWith('?'))
{
formattedQuestion += "?";
}
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(formattedQuestion);
}
}
/// <summary>
/// Aggregates responses from all AI agents into a comprehensive answer.
/// This is the Fan-in point where parallel results are collected.
/// </summary>
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
{
public override ValueTask<string> HandleAsync(
string[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
" AI EXPERT PANEL RESPONSES\n" +
"═══════════════════════════════════════════════════════════════\n\n";
for (int i = 0; i < message.Length; i++)
{
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
}
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
"═══════════════════════════════════════════════════════════════";
return ValueTask.FromResult(aggregatedResult);
}
}
@@ -0,0 +1,45 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using OpenAI.Chat;
using WorkflowConcurrency;
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
// Create Azure OpenAI client
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
// Define the 4 executors for the workflow
ParseQuestionExecutor parseQuestion = new();
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
AggregatorExecutor aggregator = new();
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
Workflow workflow = new WorkflowBuilder(parseQuestion)
.WithName("ExpertReview")
.AddFanOutEdge(parseQuestion, [physicist, chemist])
.AddFanInBarrierEdge([physicist, chemist], aggregator)
.Build();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflows(workflow))
.Build();
app.Run();
@@ -0,0 +1,90 @@
# Concurrent Workflow Sample
This sample demonstrates how to use the Microsoft Agent Framework to create an Azure Functions app that orchestrates concurrent execution of multiple AI agents using the fan-out/fan-in pattern within a durable workflow.
## Key Concepts Demonstrated
- Defining workflows with fan-out/fan-in edges for parallel execution using `WorkflowBuilder`
- Mixing custom executors with AI agents in a single workflow
- Concurrent execution of multiple AI agents (physics and chemistry experts)
- Response aggregation from parallel branches into a unified result
- Durable orchestration with automatic checkpointing and resumption from failures
- Viewing workflow execution history and status in the Durable Task Scheduler (DTS) dashboard
## Workflow
This sample defines a single workflow:
**ExpertReview**: `ParseQuestion` → [`Physicist`, `Chemist`] (parallel) → `Aggregator`
1. **ParseQuestion** — A custom executor that validates and formats the incoming question.
2. **Physicist** and **Chemist** — AI agents that run concurrently, each providing an expert perspective.
3. **Aggregator** — A custom executor that combines the parallel responses into a comprehensive answer.
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
This sample requires Azure OpenAI. Set the following environment variables:
- `AZURE_OPENAI_ENDPOINT` — Your Azure OpenAI endpoint URL.
- `AZURE_OPENAI_DEPLOYMENT` — The name of your chat model deployment.
- `AZURE_OPENAI_KEY` (optional) — Your Azure OpenAI API key. If not set, Azure CLI credentials are used.
## Running the Sample
With the environment setup and function app running, you can test the sample by sending an HTTP request with a science question to the workflow endpoint.
You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below:
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpertReview/run \
-H "Content-Type: text/plain" \
-d "What is temperature?"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpertReview/run `
-ContentType text/plain `
-Body "What is temperature?"
```
The response will confirm the workflow orchestration has started:
```text
Workflow orchestration started for ExpertReview. Orchestration runId: abc123def456
```
> **Tip:** You can provide a custom run ID by appending a `runId` query parameter:
>
> ```bash
> curl -X POST "http://localhost:7071/api/workflows/ExpertReview/run?runId=my-review-123" \
> -H "Content-Type: text/plain" \
> -d "What is temperature?"
> ```
>
> If not provided, a unique run ID is auto-generated.
In the function app logs, you will see the fan-out/fan-in execution pattern:
```text
│ [ParseQuestion] Preparing question for AI agents...
│ [ParseQuestion] Question: "What is temperature?"
│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...
│ [Aggregator] 📋 Received 2 AI agent responses
│ [Aggregator] Combining into comprehensive answer...
│ [Aggregator] ✓ Aggregation complete!
```
The Physicist and Chemist AI agents execute concurrently, and the Aggregator combines their responses into a formatted expert panel result.
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration, inspect inputs/outputs for each step, and view execution history.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
@@ -0,0 +1,14 @@
# Default endpoint address for local testing
@authority=http://localhost:7071
### Prompt the agent
POST {{authority}}/api/workflows/ExpertReview/run
Content-Type: text/plain
What is temperature?
### Start with a custom run ID
POST {{authority}}/api/workflows/ExpertReview/run?runId=my-review-123
Content-Type: text/plain
What is gravity?
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowHITLFunctions</AssemblyName>
<RootNamespace>WorkflowHITLFunctions</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="local.settings.json" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.Hosting.AzureFunctions" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowHITLFunctions;
/// <summary>Expense approval request passed to the RequestPort.</summary>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
/// <summary>Approval response received from the RequestPort.</summary>
public record ApprovalResponse(bool Approved, string? Comments);
/// <summary>Looks up expense details and creates an approval request.</summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
public override ValueTask<ApprovalRequest> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// In a real scenario, this would look up expense details from a database
return new ValueTask<ApprovalRequest>(new ApprovalRequest(message, 1500.00m, "Jerry"));
}
}
/// <summary>Prepares the approval request for finance review after manager approval.</summary>
internal sealed class PrepareFinanceReview() : Executor<ApprovalResponse, ApprovalRequest>("PrepareFinanceReview")
{
public override ValueTask<ApprovalRequest> HandleAsync(
ApprovalResponse message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
if (!message.Approved)
{
throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense.");
}
// In a real scenario, this would retrieve the original expense details
return new ValueTask<ApprovalRequest>(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry"));
}
}
/// <summary>Processes the expense reimbursement based on the parallel approval responses.</summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
{
public override async ValueTask<string> HandleAsync(
ApprovalResponse[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Check that all parallel approvals passed
ApprovalResponse? denied = Array.Find(message, r => !r.Approved);
if (denied is not null)
{
return $"Expense reimbursement denied. Comments: {denied.Comments}";
}
// Simulate payment processing
await Task.Delay(1000, cancellationToken);
return $"Expense reimbursed at {DateTime.UtcNow:O}";
}
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions.
//
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
// │ ├─►│ExpenseReimburse │
// │ ┌────────────────────┐ │ └─────────────────┘
// └►│ComplianceApproval │──┘
// │ (RequestPort) │
// └────────────────────┘
//
// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance.
// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in.
// The framework auto-generates three HTTP endpoints for each workflow:
// POST /api/workflows/{name}/run - Start the workflow
// GET /api/workflows/{name}/status/{id} - Check status and pending approvals
// POST /api/workflows/{name}/respond/{id} - Send approval response to resume
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using WorkflowHITLFunctions;
// Define executors and RequestPorts for the three HITL pause points
CreateApprovalRequest createRequest = new();
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
PrepareFinanceReview prepareFinanceReview = new();
RequestPort<ApprovalRequest, ApprovalResponse> budgetApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("BudgetApproval");
RequestPort<ApprovalRequest, ApprovalResponse> complianceApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ComplianceApproval");
ExpenseReimburse reimburse = new();
// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse
Workflow expenseApproval = new WorkflowBuilder(createRequest)
.WithName("ExpenseReimbursement")
.WithDescription("Expense reimbursement with manager and parallel finance approvals")
.AddEdge(createRequest, managerApproval)
.AddEdge(managerApproval, prepareFinanceReview)
.AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval])
.AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse)
.Build();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true))
.Build();
app.Run();
@@ -0,0 +1,266 @@
# Human-in-the-Loop (HITL) Workflow — Azure Functions
This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at three `RequestPort` nodes — one sequential manager approval, then two parallel finance approvals (budget and compliance) via fan-out/fan-in. Approval responses are sent via HTTP endpoints.
## Key Concepts Demonstrated
- Using multiple `RequestPort` nodes for sequential and parallel human-in-the-loop interactions in a durable workflow
- Fan-out/fan-in pattern for parallel approval steps
- Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses
- Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync`
- Viewing inputs the workflow is waiting for via the status endpoint
## Workflow
This sample implements the following workflow:
```
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
│ ├─►│ExpenseReimburse │
│ ┌────────────────────┐ │ └─────────────────┘
└►│ComplianceApproval │──┘
│ (RequestPort) │
└────────────────────┘
```
## HTTP Endpoints
The framework auto-generates these endpoints for workflows with `RequestPort` nodes:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow |
| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and inputs the workflow is waiting for |
| POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume |
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on how to configure the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints.
You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below:
### Step 1: Start the Workflow
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/run \
-H "Content-Type: text/plain" -d "EXP-2025-001"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/run `
-ContentType text/plain `
-Body "EXP-2025-001"
```
The response will confirm the workflow orchestration has started:
```text
Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456
```
> [!TIP]
> You can provide a custom run ID by appending a `runId` query parameter:
>
> Bash (Linux/macOS/WSL):
>
> ```bash
> curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \
> -H "Content-Type: text/plain" -d "EXP-2025-001"
> ```
>
> PowerShell:
>
> ```powershell
> Invoke-RestMethod -Method Post `
> -Uri "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" `
> -ContentType text/plain `
> -Body "EXP-2025-001"
> ```
>
> If not provided, a unique run ID is auto-generated.
### Step 2: Check Workflow Status
The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Running",
"waitingForInput": [
{ "eventName": "ManagerApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }
]
}
```
> [!TIP]
> You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event.
### Step 3: Send Manager Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "ManagerApproval",
"validated": true
}
```
### Step 4: Check Workflow Status Again
The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Running",
"waitingForInput": [
{ "eventName": "BudgetApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } },
{ "eventName": "ComplianceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }
]
}
```
### Step 5a: Send Budget Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "BudgetApproval",
"validated": true
}
```
### Step 5b: Send Compliance Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "ComplianceApproval",
"validated": true
}
```
### Step 6: Check Final Status
After all approvals, the workflow completes and the expense is reimbursed:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Completed",
"waitingForInput": null
}
```
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the orchestration and inspect its execution history.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
1. Open the dashboard and look for the orchestration instance matching the `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`).
2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals.
3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received.
@@ -0,0 +1,53 @@
# Default endpoint address for local testing
@authority=http://localhost:7071
### Step 1: Start the expense reimbursement workflow
POST {{authority}}/api/workflows/ExpenseReimbursement/run
Content-Type: text/plain
EXP-2025-001
### Step 1 (alternative): Start the workflow with a custom run ID
POST {{authority}}/api/workflows/ExpenseReimbursement/run?runId=expense-001
Content-Type: text/plain
EXP-2025-001
### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1)
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
### Step 3: Send manager approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}
### Step 3 (alternative): Deny the expense at manager level
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}}
### Step 4: Check workflow status after manager approval (now waiting for parallel finance approvals)
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
### Step 5a: Send budget approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}
### Step 5b: Send compliance approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}
### Step 5b (alternative): Deny the expense at compliance level
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ComplianceApproval", "response": {"Approved": false, "Comments": "Compliance requirements not met."}}
### Step 6: Check final workflow status after all approvals
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SequentialWorkflow</AssemblyName>
<RootNamespace>SequentialWorkflow</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SequentialWorkflow;
/// <summary>
/// Represents a request to cancel an order.
/// </summary>
/// <param name="OrderId">The ID of the order to cancel.</param>
/// <param name="Reason">The reason for cancellation.</param>
internal sealed record OrderCancelRequest(string OrderId, string Reason);
/// <summary>
/// Looks up an order by its ID and return an Order object.
/// </summary>
internal sealed class OrderLookup() : Executor<OrderCancelRequest, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(
OrderCancelRequest message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'");
Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'");
Console.ResetColor();
// Simulate database lookup with delay
await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken);
Order order = new(
Id: message.OrderId,
OrderDate: DateTime.UtcNow.AddDays(-1),
IsCancelled: false,
CancelReason: message.Reason,
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return order;
}
}
/// <summary>
/// Cancels an order.
/// </summary>
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
{
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Log that this activity is executing (not replaying)
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
Console.ResetColor();
// Simulate a slow cancellation process (e.g., calling external payment system)
for (int i = 1; i <= 3; i++)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
Console.ResetColor();
}
Order cancelledOrder = message with { IsCancelled = true };
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return cancelledOrder;
}
}
/// <summary>
/// Sends a cancellation confirmation email to the customer.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
Console.ResetColor();
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(result);
}
}
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
internal sealed record Customer(string Name, string Email);
@@ -0,0 +1,93 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SequentialWorkflow;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors for the workflow
OrderLookup orderLookup = new();
OrderCancel orderCancel = new();
SendEmail sendEmail = new();
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
.WithName("CancelOrder")
.WithDescription("Cancel an order and notify the customer")
.AddEdge(orderLookup, orderCancel)
.AddEdge(orderCancel, sendEmail)
.Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Durable Workflow Sample");
Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail");
Console.WriteLine();
Console.WriteLine("Enter an order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation");
await StartNewWorkflowAsync(request, cancelOrder, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow using IWorkflowClient with typed input
static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})...");
// RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request);
Console.WriteLine($"Run ID: {run.RunId}");
try
{
Console.WriteLine("Waiting for workflow to complete...");
string? result = await run.WaitForCompletionAsync<string>();
Console.WriteLine($"Workflow completed. {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
@@ -0,0 +1,83 @@
# Sequential Workflow Sample
This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities.
## Key Concepts Demonstrated
- Building a sequential workflow with the `WorkflowBuilder` API
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
- Running workflows with `IWorkflowClient`
- **Durability**: Automatic resume of interrupted workflows
- **Activity caching**: Completed activities are not re-executed on replay
## Overview
The sample implements an order cancellation workflow with three executors:
```
OrderLookup --> OrderCancel --> SendEmail
```
| Executor | Description |
|----------|-------------|
| OrderLookup | Looks up an order by ID |
| OrderCancel | Marks the order as cancelled |
| SendEmail | Sends a cancellation confirmation email |
## Durability Demonstration
The key feature of Durable Task Framework is **durability**:
- **Activity results are persisted**: When an activity completes, its result is saved
- **Orchestrations replay**: On restart, the orchestration replays from the beginning
- **Completed activities skip execution**: The framework uses cached results
- **Automatic resume**: The worker automatically picks up pending work on startup
### Try It Yourself
> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`.
1. Start the application and enter an order ID (e.g., `12345`)
2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel`
3. Restart the application
4. Observe:
- `OrderLookup` is **NOT** re-executed (result was cached)
- `OrderCancel` **restarts** (it didn't complete before the interruption)
- `SendEmail` runs after `OrderCancel` completes
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow
dotnet run --framework net10.0
```
### Sample Output
```text
Durable Workflow Sample
Workflow: OrderLookup -> OrderCancel -> SendEmail
Enter an order ID (or 'exit'):
> 12345
Starting workflow for order: 12345
Run ID: abc123...
[OrderLookup] Looking up order '12345'...
[OrderLookup] Found order for customer 'Jerry'
[OrderCancel] Cancelling order '12345'...
[OrderCancel] Order cancelled successfully
[SendEmail] Sending email to 'jerry@example.com'...
[SendEmail] Email sent successfully
Workflow completed!
> exit
```
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowConcurrency</AssemblyName>
<RootNamespace>WorkflowConcurrency</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Azure.AI.OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowConcurrency;
/// <summary>
/// Parses and validates the incoming question before sending to AI agents.
/// </summary>
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
{
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
string formattedQuestion = message.Trim();
if (!formattedQuestion.EndsWith('?'))
{
formattedQuestion += "?";
}
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(formattedQuestion);
}
}
/// <summary>
/// Aggregates responses from all AI agents into a comprehensive answer.
/// This is the Fan-in point where parallel results are collected.
/// </summary>
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
{
public override ValueTask<string> HandleAsync(
string[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
" AI EXPERT PANEL RESPONSES\n" +
"═══════════════════════════════════════════════════════════════\n\n";
for (int i = 0; i < message.Length; i++)
{
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
}
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
"═══════════════════════════════════════════════════════════════";
return ValueTask.FromResult(aggregatedResult);
}
}
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow.
// The workflow uses 4 executors: 2 class-based executors and 2 AI agents.
//
// WORKFLOW PATTERN:
//
// ParseQuestion (class-based)
// |
// +----------+----------+
// | |
// Physicist Chemist
// (AI Agent) (AI Agent)
// | |
// +----------+----------+
// |
// Aggregator (class-based)
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using WorkflowConcurrency;
// Configuration
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
// Create Azure OpenAI client
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
// Define the 4 executors for the workflow
ParseQuestionExecutor parseQuestion = new();
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
AggregatorExecutor aggregator = new();
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
Workflow workflow = new WorkflowBuilder(parseQuestion)
.WithName("ExpertReview")
.AddFanOutEdge(parseQuestion, [physicist, chemist])
.AddFanInBarrierEdge([physicist, chemist], aggregator)
.Build();
// Configure and start the host
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableOptions(
options => options.Workflows.AddWorkflow(workflow),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Fan-out/Fan-in Workflow Sample");
Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator");
Console.WriteLine();
Console.WriteLine("Enter a science question (or 'exit' to quit):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
IWorkflowRun run = await workflowClient.RunAsync(workflow, input);
Console.WriteLine($"Run ID: {run.RunId}");
if (run is IAwaitableWorkflowRun awaitableRun)
{
string? result = await awaitableRun.WaitForCompletionAsync<string>();
Console.WriteLine("Workflow completed!");
Console.WriteLine(result);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
@@ -0,0 +1,100 @@
# Concurrent Workflow Sample (Fan-Out/Fan-In)
This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel.
## Key Concepts Demonstrated
- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation
- **Mixed executor types**: Class-based executors and AI agents in the same workflow
- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents
- **Workflow registration**: Auto-registration of agents used within workflows
- **Standalone agents**: Registering agents outside of workflows
## Overview
The sample implements an expert review workflow with four executors:
```
ParseQuestion
|
+----------+----------+
| |
Physicist Chemist
(AI Agent) (AI Agent)
| |
+----------+----------+
|
Aggregator
```
| Executor | Type | Description |
|----------|------|-------------|
| ParseQuestion | Class-based | Parses the user's question for expert review |
| Physicist | AI Agent | Provides physics perspective (runs in parallel) |
| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) |
| Aggregator | Class-based | Combines expert responses into a final answer |
## Fan-Out/Fan-In Pattern
The workflow demonstrates the fan-out/fan-in pattern:
1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously
2. **Parallel execution**: Both AI agents process the question concurrently
3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses
This pattern is useful for:
- Gathering multiple perspectives on a problem
- Parallel processing of independent tasks
- Reducing overall execution time through concurrency
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment.
### Required Environment Variables
```bash
# Durable Task Scheduler (optional, defaults to localhost)
DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
# Azure OpenAI (required)
AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
AZURE_OPENAI_DEPLOYMENT="gpt-4o"
AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials
```
## Running the Sample
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow
dotnet run --framework net10.0
```
### Sample Output
```text
+-----------------------------------------------------------------------+
| Fan-out/Fan-in Workflow Sample (4 Executors) |
| |
| ParseQuestion -> [Physicist, Chemist] -> Aggregator |
| (class-based) (AI agents, parallel) (class-based) |
+-----------------------------------------------------------------------+
Enter a science question (or 'exit' to quit):
Question: Why is the sky blue?
Instance: abc123...
[ParseQuestion] Parsing question for expert review...
[Physicist] Analyzing from physics perspective...
[Chemist] Analyzing from chemistry perspective...
[Aggregator] Combining expert responses...
Workflow completed!
Physics perspective: The sky appears blue due to Rayleigh scattering...
Chemistry perspective: The molecular composition of our atmosphere...
Combined answer: ...
Question: exit
```
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ConditionalEdges</AssemblyName>
<RootNamespace>ConditionalEdges</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,85 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace ConditionalEdges;
internal sealed class Order
{
public Order(string id, decimal amount)
{
this.Id = id;
this.Amount = amount;
}
public string Id { get; }
public decimal Amount { get; }
public Customer? Customer { get; set; }
public string? PaymentReferenceNumber { get; set; }
}
public sealed record Customer(int Id, string Name, bool IsBlocked);
internal sealed class OrderIdParser() : Executor<string, Order>("OrderIdParser")
{
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
return GetOrder(message);
}
private static Order GetOrder(string id)
{
// Simulate fetching order details
return new Order(id, 100.0m);
}
}
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
message.Customer = GetCustomerForOrder(message.Id);
return message;
}
private static Customer GetCustomerForOrder(string orderId)
{
if (orderId.Contains('B'))
{
return new Customer(101, "George", true);
}
return new Customer(201, "Jerry", false);
}
}
internal sealed class PaymentProcessor() : Executor<Order, Order>("PaymentProcessor")
{
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Call payment gateway.
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
return message;
}
}
internal sealed class NotifyFraud() : Executor<Order, string>("NotifyFraud")
{
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
// Notify fraud team.
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
}
}
internal static class OrderRouteConditions
{
/// <summary>
/// Returns a condition that evaluates to true when the customer is blocked.
/// </summary>
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
/// <summary>
/// Returns a condition that evaluates to true when the customer is not blocked.
/// </summary>
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
}
@@ -0,0 +1,97 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates conditional edges in a workflow.
// Orders are routed to different executors based on customer status:
// - Blocked customers → NotifyFraud
// - Valid customers → PaymentProcessor
using ConditionalEdges;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Create executor instances
OrderIdParser orderParser = new();
OrderEnrich orderEnrich = new();
PaymentProcessor paymentProcessor = new();
NotifyFraud notifyFraud = new();
// Build workflow with conditional edges
// The condition functions evaluate the Order output from OrderEnrich
WorkflowBuilder builder = new(orderParser);
builder
.AddEdge(orderParser, orderEnrich)
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
Workflow auditOrder = builder.WithName("AuditOrder").Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(auditOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Enter an order ID (or 'exit'):");
Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await StartNewWorkflowAsync(input, auditOrder, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow and wait for completion
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"Starting workflow for order '{orderId}'...");
// Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId);
Console.WriteLine($"Run ID: {run.RunId}");
try
{
Console.WriteLine("Waiting for workflow to complete...");
string? result = await run.WaitForCompletionAsync<string>();
Console.WriteLine($"Workflow completed. {result}");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Failed: {ex.Message}");
}
}
@@ -0,0 +1,92 @@
# Conditional Edges Workflow Sample
This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run.
## Key Concepts Demonstrated
- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter
- Defining reusable condition functions for routing logic
- Branching workflow execution based on data-driven decisions
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
## Overview
The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud):
```
OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud
|
+--[NotBlocked]--> PaymentProcessor
```
| Executor | Description |
|----------|-------------|
| OrderIdParser | Parses the order ID and retrieves order details |
| OrderEnrich | Enriches the order with customer information |
| PaymentProcessor | Processes payment for valid orders |
| NotifyFraud | Notifies the fraud team for blocked customers |
## How Conditional Edges Work
Conditional edges allow you to specify a condition function that determines whether the edge should be traversed:
```csharp
builder
.AddEdge(orderParser, orderEnrich)
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
```
The condition functions receive the output of the source executor and return a boolean:
```csharp
internal static class OrderRouteConditions
{
// Routes to NotifyFraud when customer is blocked
internal static Func<Order?, bool> WhenBlocked() =>
order => order?.Customer?.IsBlocked == true;
// Routes to PaymentProcessor when customer is not blocked
internal static Func<Order?, bool> WhenNotBlocked() =>
order => order?.Customer?.IsBlocked == false;
}
```
### Routing Logic
In this sample, the routing is based on the order ID:
- Order IDs containing the letter **'B'** are associated with blocked customers → routed to `NotifyFraud`
- All other order IDs are associated with valid customers → routed to `PaymentProcessor`
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges
dotnet run --framework net10.0
```
### Sample Output
**Valid order (routes to PaymentProcessor):**
```text
Enter an order ID (or 'exit'):
> 12345
Starting workflow for order '12345'...
Run ID: abc123...
Waiting for workflow to complete...
Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"}
```
**Blocked order (routes to NotifyFraud):**
```text
Enter an order ID (or 'exit'):
> 12345B
Starting workflow for order '12345B'...
Run ID: def456...
Waiting for workflow to complete...
Workflow completed. Order 12345B flagged as fraudulent for customer George.
```
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowConcurrency</AssemblyName>
<RootNamespace>WorkflowConcurrency</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Azure.AI.OpenAI" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowConcurrency;
/// <summary>
/// Parses and validates the incoming question before sending to AI agents.
/// </summary>
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
{
public override ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Magenta;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
string formattedQuestion = message.Trim();
if (!formattedQuestion.EndsWith('?'))
{
formattedQuestion += "?";
}
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
Console.WriteLine("│ [ParseQuestion] → Sending to experts...");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult(formattedQuestion);
}
}
/// <summary>
/// Aggregates responses from multiple AI agents into a unified response.
/// This executor collects all expert opinions and synthesizes them.
/// </summary>
internal sealed class ResponseAggregatorExecutor() : Executor<string[], string>("ResponseAggregator")
{
public override ValueTask<string> HandleAsync(
string[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
" AI EXPERT PANEL RESPONSES\n" +
"═══════════════════════════════════════════════════════════════\n\n";
for (int i = 0; i < message.Length; i++)
{
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
}
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
"═══════════════════════════════════════════════════════════════";
return ValueTask.FromResult(aggregatedResult);
}
}
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates the THREE ways to configure durable agents and workflows:
//
// 1. ConfigureDurableAgents() - For standalone agents only
// 2. ConfigureDurableWorkflows() - For workflows only
// 3. ConfigureDurableOptions() - For both agents AND workflows
//
// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE.
using Azure;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using OpenAI.Chat;
using WorkflowConcurrency;
// Configuration
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
// Create AI agents
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist");
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist");
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist");
// Create workflows
ParseQuestionExecutor questionParser = new();
ResponseAggregatorExecutor responseAggregator = new();
Workflow physicsWorkflow = new WorkflowBuilder(questionParser)
.WithName("PhysicsExpertReview")
.AddEdge(questionParser, physicist)
.Build();
Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser)
.WithName("ExpertTeamReview")
.AddFanOutEdge(questionParser, [biologist, physicist])
.AddFanInBarrierEdge([biologist, physicist], responseAggregator)
.Build();
Workflow chemistryWorkflow = new WorkflowBuilder(questionParser)
.WithName("ChemistryExpertReview")
.AddEdge(questionParser, chemist)
.Build();
// Configure services - demonstrating all 3 methods (each can be called multiple times)
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
// METHOD 1: ConfigureDurableAgents - for standalone agents only
services.ConfigureDurableAgents(
options => options.AddAIAgent(biologist),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
// METHOD 2: ConfigureDurableWorkflows - for workflows only
services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow));
// METHOD 3: ConfigureDurableOptions - for both agents AND workflows
services.ConfigureDurableOptions(options =>
{
options.Agents.AddAIAgent(chemist);
options.Workflows.AddWorkflow(expertTeamWorkflow);
});
// Second call to ConfigureDurableOptions (additive - adds to existing config)
services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow));
})
.Build();
await host.StartAsync();
IServiceProvider services = host.Services;
IWorkflowClient workflowClient = services.GetRequiredService<IWorkflowClient>();
// DEMO 1: Direct agent conversation (standalone agents)
Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n");
AIAgent biologistProxy = services.GetRequiredKeyedService<AIAgent>("Biologist");
AgentSession session = await biologistProxy.CreateSessionAsync();
AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session);
Console.WriteLine($"🧬 Biologist: {response.Text}\n");
AIAgent chemistProxy = services.GetRequiredKeyedService<AIAgent>("Chemist");
session = await chemistProxy.CreateSessionAsync();
response = await chemistProxy.RunAsync("What is a chemical bond?", session);
Console.WriteLine($"🧪 Chemist: {response.Text}\n");
// DEMO 2: Single-agent workflow
Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n");
await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?");
// DEMO 3: Multi-agent workflow
Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n");
await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?");
// DEMO 4: Workflow from second ConfigureDurableOptions call
Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n");
await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?");
Console.WriteLine("\n✅ All demos completed!");
await host.StopAsync();
// Helper method
static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question)
{
Console.WriteLine($"📋 {workflow.Name}: \"{question}\"");
IWorkflowRun run = await client.RunAsync(workflow, question);
if (run is IAwaitableWorkflowRun awaitable)
{
string? result = await awaitable.WaitForCompletionAsync<string>();
Console.WriteLine($"✅ {result}\n");
}
}
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowEvents</AssemblyName>
<RootNamespace>WorkflowEvents</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,129 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowEvents;
// ═══════════════════════════════════════════════════════════════════════════════
// Custom event types - callers observe these via WatchStreamAsync
// ═══════════════════════════════════════════════════════════════════════════════
internal sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent(orderId)
{
public string OrderId { get; } = orderId;
}
internal sealed class OrderFoundEvent(string customerName) : WorkflowEvent(customerName)
{
public string CustomerName { get; } = customerName;
}
internal sealed class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
{
public int PercentComplete { get; } = percentComplete;
public string Status { get; } = status;
}
internal sealed class OrderCancelledEvent() : WorkflowEvent("Order cancelled");
internal sealed class EmailSentEvent(string email) : WorkflowEvent(email)
{
public string Email { get; } = email;
}
// ═══════════════════════════════════════════════════════════════════════════════
// Domain models
// ═══════════════════════════════════════════════════════════════════════════════
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
internal sealed record Customer(string Name, string Email);
// ═══════════════════════════════════════════════════════════════════════════════
// Executors - emit events via AddEventAsync and YieldOutputAsync
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// Looks up an order by ID, emitting progress events.
/// </summary>
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
{
public override async ValueTask<Order> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken);
// Simulate database lookup
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
Order order = new(
Id: message,
OrderDate: DateTime.UtcNow.AddDays(-1),
IsCancelled: false,
CancelReason: "Customer requested cancellation",
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
await context.AddEventAsync(new OrderFoundEvent(order.Customer.Name), cancellationToken);
// YieldOutputAsync emits a WorkflowOutputEvent observable via streaming
await context.YieldOutputAsync(order, cancellationToken);
return order;
}
}
/// <summary>
/// Cancels an order, emitting progress events during the multi-step process.
/// </summary>
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
{
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await context.AddEventAsync(new CancellationProgressEvent(0, "Starting cancellation"), cancellationToken);
// Simulate a multi-step cancellation process
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
await context.AddEventAsync(new CancellationProgressEvent(33, "Contacting payment provider"), cancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
await context.AddEventAsync(new CancellationProgressEvent(66, "Processing refund"), cancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
Order cancelledOrder = message with { IsCancelled = true };
await context.AddEventAsync(new CancellationProgressEvent(100, "Complete"), cancellationToken);
await context.AddEventAsync(new OrderCancelledEvent(), cancellationToken);
await context.YieldOutputAsync(cancelledOrder, cancellationToken);
return cancelledOrder;
}
}
/// <summary>
/// Sends a cancellation confirmation email, emitting an event on completion.
/// </summary>
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
{
public override async ValueTask<string> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Simulate sending email
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
await context.AddEventAsync(new EmailSentEvent(message.Customer.Email), cancellationToken);
await context.YieldOutputAsync(result, cancellationToken);
return result;
}
}
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft. All rights reserved.
// ═══════════════════════════════════════════════════════════════════════════════
// SAMPLE: Workflow Events and Streaming
// ═══════════════════════════════════════════════════════════════════════════════
//
// This sample demonstrates how to use IWorkflowContext event methods in executors
// and stream events from the caller side:
//
// 1. AddEventAsync - Emit custom events that callers can observe in real-time
// 2. StreamAsync - Start a workflow and obtain a streaming handle
// 3. WatchStreamAsync - Observe events as they occur (custom, framework, and terminal)
//
// The sample uses IWorkflowClient.StreamAsync to start a workflow and
// WatchStreamAsync to observe events as they occur in real-time.
//
// Workflow: OrderLookup -> OrderCancel -> SendEmail
// ═══════════════════════════════════════════════════════════════════════════════
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WorkflowEvents;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors and build workflow
OrderLookup orderLookup = new();
OrderCancel orderCancel = new();
SendEmail sendEmail = new();
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
.WithName("CancelOrder")
.WithDescription("Cancel an order and notify the customer")
.AddEdge(orderLookup, orderCancel)
.AddEdge(orderCancel, sendEmail)
.Build();
// Configure host with durable workflow support
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await RunWorkflowWithStreamingAsync(input, cancelOrder, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Runs a workflow and streams events as they occur
static async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client)
{
// StreamAsync starts the workflow and returns a streaming handle for observing events
IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId);
Console.WriteLine($"Started run: {run.RunId}");
// WatchStreamAsync yields events as they're emitted by executors
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
Console.WriteLine($" New event received at {DateTime.Now:HH:mm:ss.ffff} ({evt.GetType().Name})");
switch (evt)
{
// Custom domain events (emitted via AddEventAsync)
case OrderLookupStartedEvent e:
WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
break;
case OrderFoundEvent e:
WriteColored($" [Lookup] Found: {e.CustomerName}", ConsoleColor.Cyan);
break;
case CancellationProgressEvent e:
WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
break;
case OrderCancelledEvent:
WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
break;
case EmailSentEvent e:
WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
break;
case WorkflowOutputEvent e:
WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
break;
// Workflow completion
case DurableWorkflowCompletedEvent e:
WriteColored($" Completed: {e.Result}", ConsoleColor.Green);
break;
case DurableWorkflowFailedEvent e:
WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red);
break;
}
}
}
static void WriteColored(string message, ConsoleColor color)
{
Console.ForegroundColor = color;
Console.WriteLine(message);
Console.ResetColor();
}
@@ -0,0 +1,127 @@
# Workflow Events Sample
This sample demonstrates how to use workflow events and streaming in durable workflows.
## What it demonstrates
1. **Custom Events** (`AddEventAsync`) — Executors emit domain-specific events during execution
2. **Event Streaming** (`StreamAsync` / `WatchStreamAsync`) — Callers observe events in real-time as the workflow progresses
3. **Framework Events** — Automatic `ExecutorInvokedEvent`, `ExecutorCompletedEvent`, and `WorkflowOutputEvent` events emitted by the framework
## Emitting Custom Events
Executors can emit custom domain events during execution using the `IWorkflowContext` instance passed to `HandleAsync`. These events are streamed to callers in real-time via `WatchStreamAsync`.
### Defining a custom event
Create a class that inherits from `WorkflowEvent`. Pass any data payload to the base constructor:
```csharp
public class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
{
public int PercentComplete { get; } = percentComplete;
public string Status { get; } = status;
}
```
### Emitting the event from an executor
Call `AddEventAsync` on the `IWorkflowContext` inside your executor's `HandleAsync` method:
```csharp
public override async ValueTask<Order> HandleAsync(
Order message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await context.AddEventAsync(new CancellationProgressEvent(33, "Processing refund"), cancellationToken);
// ... rest of the executor logic
}
```
### Observing events from the caller
Use `StreamAsync` to start the workflow and `WatchStreamAsync` to observe events. Pattern match on your custom event types:
```csharp
IStreamingWorkflowRun run = await workflowClient.StreamAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case CancellationProgressEvent e:
Console.WriteLine($"{e.PercentComplete}% - {e.Status}");
break;
}
}
```
## Workflow Structure
```
OrderLookup → OrderCancel → SendEmail
```
Each executor emits custom events during execution:
- `OrderLookup` emits `OrderLookupStartedEvent` and `OrderFoundEvent`
- `OrderCancel` emits `CancellationProgressEvent` (with percentage) and `OrderCancelledEvent`
- `SendEmail` emits `EmailSentEvent`
## Prerequisites
- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure
- Set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable (defaults to local emulator)
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the sample
```bash
dotnet run
```
Enter an order ID at the prompt to start a workflow and watch events stream in real-time:
```text
> order-42
Started run: b6ba4d19...
New event received at 13:27:41.4956 (ExecutorInvokedEvent)
New event received at 13:27:41.5019 (OrderLookupStartedEvent)
[Lookup] Looking up order order-42
New event received at 13:27:41.5025 (OrderFoundEvent)
[Lookup] Found: Jerry
New event received at 13:27:41.5026 (ExecutorCompletedEvent)
New event received at 13:27:41.5026 (WorkflowOutputEvent)
[Output] OrderLookup
New event received at 13:27:43.0772 (ExecutorInvokedEvent)
New event received at 13:27:43.0773 (CancellationProgressEvent)
[Cancel] 0% - Starting cancellation
New event received at 13:27:43.0775 (CancellationProgressEvent)
[Cancel] 33% - Contacting payment provider
New event received at 13:27:43.0776 (CancellationProgressEvent)
[Cancel] 66% - Processing refund
New event received at 13:27:43.0777 (CancellationProgressEvent)
[Cancel] 100% - Complete
New event received at 13:27:43.0779 (OrderCancelledEvent)
[Cancel] Done
New event received at 13:27:43.0780 (ExecutorCompletedEvent)
New event received at 13:27:43.0780 (WorkflowOutputEvent)
[Output] OrderCancel
New event received at 13:27:43.6610 (ExecutorInvokedEvent)
New event received at 13:27:43.6611 (EmailSentEvent)
[Email] Sent to jerry@example.com
New event received at 13:27:43.6613 (ExecutorCompletedEvent)
New event received at 13:27:43.6613 (WorkflowOutputEvent)
[Output] SendEmail
New event received at 13:27:43.6619 (DurableWorkflowCompletedEvent)
Completed: Cancellation email sent for order order-42 to jerry@example.com.
```
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the workflow execution and events.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowSharedState</AssemblyName>
<RootNamespace>WorkflowSharedState</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,182 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowSharedState;
// ═══════════════════════════════════════════════════════════════════════════════
// Domain models
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// The primary order data passed through the pipeline via return values.
/// </summary>
internal sealed record OrderDetails(string OrderId, string CustomerName, decimal Amount, DateTime OrderDate);
/// <summary>
/// Cross-cutting audit trail accumulated in shared state across executors.
/// Each executor appends its step name and timestamp. This data does not flow
/// through return values — it lives only in shared state.
/// </summary>
internal sealed record AuditEntry(string Step, string Timestamp, string Detail);
// ═══════════════════════════════════════════════════════════════════════════════
// Executors
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// Validates the order and writes the initial audit entry and tax rate to shared state.
/// The order details are returned as the executor output (normal message flow),
/// while the audit trail and tax rate are stored in shared state (side-channel).
/// If the order ID starts with "INVALID", the executor halts the workflow early
/// using <see cref="IWorkflowContext.RequestHaltAsync"/>.
/// </summary>
internal sealed class ValidateOrder() : Executor<string, OrderDetails>("ValidateOrder")
{
public override async ValueTask<OrderDetails> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
// Halt the workflow early if the order ID is invalid.
// No downstream executors will run after this.
if (message.StartsWith("INVALID", StringComparison.OrdinalIgnoreCase))
{
await context.YieldOutputAsync($"Order '{message}' failed validation. Halting workflow.", cancellationToken);
await context.RequestHaltAsync();
return new OrderDetails(message, "Unknown", 0, DateTime.UtcNow);
}
OrderDetails details = new(message, "Jerry", 249.99m, DateTime.UtcNow);
// Store the tax rate in shared state — downstream ProcessPayment reads it
// without needing it in the message chain.
await context.QueueStateUpdateAsync("taxRate", 0.085m, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: taxRate = 8.5%");
// Start the audit trail in shared state
AuditEntry audit = new("ValidateOrder", DateTime.UtcNow.ToString("o"), $"Validated order {message}");
await context.QueueStateUpdateAsync("auditValidate", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: auditValidate");
await context.YieldOutputAsync($"Order '{message}' validated. Customer: {details.CustomerName}, Amount: {details.Amount:C}", cancellationToken);
return details;
}
}
/// <summary>
/// Enriches the order with shipping information.
/// Reads the audit trail from shared state and appends its own entry.
/// Uses ReadOrInitStateAsync to lazily initialize a shipping tier.
/// Demonstrates custom scopes by writing shipping details under the "shipping" scope.
/// </summary>
internal sealed class EnrichOrder() : Executor<OrderDetails, OrderDetails>("EnrichOrder")
{
public override async ValueTask<OrderDetails> HandleAsync(
OrderDetails message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
// Use ReadOrInitStateAsync — only initializes if no value exists yet
string shippingTier = await context.ReadOrInitStateAsync(
"shippingTier",
() => "Express",
cancellationToken: cancellationToken);
Console.WriteLine($" Read from shared state: shippingTier = {shippingTier}");
// Write carrier under a custom "shipping" scope.
// This keeps the key separate from keys written without a scope,
// so "carrier" here won't collide with a "carrier" key written elsewhere.
await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: carrier = Contoso Express (scope: shipping)");
// Verify we can read the audit entry from the previous step
AuditEntry? previousAudit = await context.ReadStateAsync<AuditEntry>("auditValidate", cancellationToken: cancellationToken);
string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)";
Console.WriteLine($" Read from shared state: auditValidate {auditStatus}");
// Append our own audit entry
AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}");
await context.QueueStateUpdateAsync("auditEnrich", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: auditEnrich");
await context.YieldOutputAsync($"Order enriched. Shipping: {shippingTier} {auditStatus}", cancellationToken);
return message;
}
}
/// <summary>
/// Processes payment using the tax rate from shared state (written by ValidateOrder).
/// The tax rate is side-channel data — it doesn't flow through return values.
/// </summary>
internal sealed class ProcessPayment() : Executor<OrderDetails, string>("ProcessPayment")
{
public override async ValueTask<string> HandleAsync(
OrderDetails message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken);
// Read tax rate written by ValidateOrder — not available in the message chain
decimal taxRate = await context.ReadOrInitStateAsync("taxRate", () => 0.0m, cancellationToken: cancellationToken);
Console.WriteLine($" Read from shared state: taxRate = {taxRate:P1}");
decimal tax = message.Amount * taxRate;
decimal total = message.Amount + tax;
string paymentRef = $"PAY-{Guid.NewGuid():N}"[..16];
// Append audit entry
AuditEntry audit = new("ProcessPayment", DateTime.UtcNow.ToString("o"), $"Charged {total:C} (tax: {tax:C})");
await context.QueueStateUpdateAsync("auditPayment", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: auditPayment");
await context.YieldOutputAsync($"Payment processed. Total: {total:C} (tax: {tax:C}). Ref: {paymentRef}", cancellationToken);
return paymentRef;
}
}
/// <summary>
/// Generates the final invoice by reading the full audit trail from shared state.
/// Demonstrates reading multiple state entries written by different executors
/// and clearing a scope with <see cref="IWorkflowContext.QueueClearScopeAsync(string?, CancellationToken)"/>.
/// </summary>
internal sealed class GenerateInvoice() : Executor<string, string>("GenerateInvoice")
{
public override async ValueTask<string> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
// Read the full audit trail from shared state — each step wrote its own entry
AuditEntry? validateAudit = await context.ReadStateAsync<AuditEntry>("auditValidate", cancellationToken: cancellationToken);
AuditEntry? enrichAudit = await context.ReadStateAsync<AuditEntry>("auditEnrich", cancellationToken: cancellationToken);
AuditEntry? paymentAudit = await context.ReadStateAsync<AuditEntry>("auditPayment", cancellationToken: cancellationToken);
int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null);
Console.WriteLine($" Read from shared state: {auditCount} audit entries");
// Read carrier from the "shipping" scope (written by EnrichOrder)
string? carrier = await context.ReadStateAsync<string>("carrier", scopeName: "shipping", cancellationToken: cancellationToken);
Console.WriteLine($" Read from shared state: carrier = {carrier} (scope: shipping)");
// Clear the "shipping" scope — no longer needed after invoice generation.
await context.QueueClearScopeAsync("shipping", cancellationToken);
Console.WriteLine(" Cleared shared state scope: shipping");
string auditSummary = string.Join(" → ", new[]
{
validateAudit?.Step, enrichAudit?.Step, paymentAudit?.Step
}.Where(s => s is not null));
return $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]";
}
}
@@ -0,0 +1,117 @@
// Copyright (c) Microsoft. All rights reserved.
// ═══════════════════════════════════════════════════════════════════════════════
// SAMPLE: Shared State During Workflow Execution
// ═══════════════════════════════════════════════════════════════════════════════
//
// This sample demonstrates how executors in a durable workflow can share state
// via IWorkflowContext. State is persisted across supersteps and survives
// process restarts because the orchestration passes it to each activity.
//
// Key concepts:
// 1. QueueStateUpdateAsync - Write a value to shared state
// 2. ReadStateAsync - Read a value written by a previous executor
// 3. ReadOrInitStateAsync - Read or lazily initialize a state value
// 4. QueueClearScopeAsync - Clear all entries under a scope
// 5. RequestHaltAsync - Stop the workflow early (e.g., validation failure)
//
// Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
//
// Return values carry primary business data through the pipeline (OrderDetails,
// payment ref). Shared state carries side-channel data that doesn't belong in
// the message chain: a tax rate (set by ValidateOrder, read by ProcessPayment)
// and an audit trail (each executor appends its own entry).
// ═══════════════════════════════════════════════════════════════════════════════
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WorkflowSharedState;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors
ValidateOrder validateOrder = new();
EnrichOrder enrichOrder = new();
ProcessPayment processPayment = new();
GenerateInvoice generateInvoice = new();
// Build the workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
Workflow orderPipeline = new WorkflowBuilder(validateOrder)
.WithName("OrderPipeline")
.WithDescription("Order processing pipeline with shared state across executors")
.AddEdge(validateOrder, enrichOrder)
.AddEdge(enrichOrder, processPayment)
.AddEdge(processPayment, generateInvoice)
.Build();
// Configure host with durable workflow support
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(orderPipeline),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Shared State Workflow Demo");
Console.WriteLine("Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice");
Console.WriteLine();
Console.WriteLine("Enter an order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
// Start the workflow and stream events to see shared state in action
IStreamingWorkflowRun run = await workflowClient.StreamAsync(orderPipeline, input);
Console.WriteLine($"Started run: {run.RunId}");
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case WorkflowOutputEvent e:
Console.WriteLine($" [Output] {e.ExecutorId}: {e.Data}");
break;
case DurableWorkflowCompletedEvent e:
Console.WriteLine($" Completed: {e.Result}");
break;
case DurableWorkflowFailedEvent e:
Console.WriteLine($" Failed: {e.ErrorMessage}");
break;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
@@ -0,0 +1,71 @@
# Shared State Workflow Sample
This sample demonstrates how executors in a durable workflow can share state via `IWorkflowContext`. State written by one executor is accessible to all downstream executors, persisted across supersteps, and survives process restarts.
## Key Concepts Demonstrated
- Writing state with `QueueStateUpdateAsync` — executors store data for downstream executors
- Reading state with `ReadStateAsync` — executors access data written by earlier executors
- Lazy initialization with `ReadOrInitStateAsync` — initialize state only if not already present
- Custom scopes with `scopeName` — partition state into isolated namespaces (e.g., `"shipping"`)
- Clearing scopes with `QueueClearScopeAsync` — remove all entries under a scope when no longer needed
- Early termination with `RequestHaltAsync` — halt the workflow when validation fails
- State persistence across supersteps — the orchestration passes shared state to each executor
- Event streaming with `IStreamingWorkflowRun` — observe executor progress in real time
## Workflow
**OrderPipeline**: `ValidateOrder``EnrichOrder``ProcessPayment``GenerateInvoice`
Return values carry primary business data through the pipeline (`OrderDetails``OrderDetails` → payment ref → invoice string). Shared state carries side-channel data that doesn't belong in the message chain:
| Executor | Returns (message flow) | Reads from State | Writes to State |
|----------|----------------------|-----------------|-----------------|
| **ValidateOrder** | `OrderDetails` | — | `taxRate`, `auditValidate` |
| **EnrichOrder** | `OrderDetails` (pass-through) | `auditValidate` | `shippingTier`, `auditEnrich`, `carrier` (scope: shipping) |
| **ProcessPayment** | payment ref string | `taxRate` | `auditPayment` |
| **GenerateInvoice** | invoice string | `auditValidate`, `auditEnrich`, `auditPayment`, `carrier` (scope: shipping) | clears `shipping` scope |
> [!NOTE]
> `EnrichOrder` writes `carrier` under the `"shipping"` scope using `scopeName: "shipping"`. This keeps the key separate from keys written without a scope, so `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key written elsewhere.
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
## Running the Sample
```bash
dotnet run
```
Enter an order ID when prompted. The workflow will process the order through all four executors, streaming events as they occur:
```text
> ORD-001
Started run: abc123
Wrote to shared state: taxRate = 8.5%
Wrote to shared state: auditValidate
[Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99
Read from shared state: shippingTier = Express
Wrote to shared state: carrier = Contoso Express (scope: shipping)
Read from shared state: auditValidate (previous step: ValidateOrder)
Wrote to shared state: auditEnrich
[Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder)
Read from shared state: taxRate = 8.5%
Wrote to shared state: auditPayment
[Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456
Read from shared state: 3 audit entries
Read from shared state: carrier = Contoso Express (scope: shipping)
Cleared shared state scope: shipping
[Output] GenerateInvoice: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
Completed: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
```
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration status, executor inputs/outputs, and events.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
To inspect shared state in the dashboard, click on an executor to view its input and output. The input contains a snapshot of the shared state the executor ran with, and the output includes any state updates it made (as `stateUpdates` with scoped keys).
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>SubWorkflows</AssemblyName>
<RootNamespace>SubWorkflows</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,232 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace SubWorkflows;
/// <summary>
/// Event emitted when the fraud check risk score is calculated.
/// </summary>
internal sealed class FraudRiskAssessedEvent(int riskScore) : WorkflowEvent($"Risk score: {riskScore}/100")
{
public int RiskScore => riskScore;
}
/// <summary>
/// Represents an order being processed through the workflow.
/// </summary>
internal sealed class OrderInfo
{
public required string OrderId { get; set; }
public decimal Amount { get; set; }
public string? PaymentTransactionId { get; set; }
public string? TrackingNumber { get; set; }
public string? Carrier { get; set; }
}
// Main workflow executors
/// <summary>
/// Entry point executor that receives the order ID and creates an OrderInfo object.
/// </summary>
internal sealed class OrderReceived() : Executor<string, OrderInfo>("OrderReceived")
{
public override ValueTask<OrderInfo> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"[OrderReceived] Processing order '{message}'");
Console.ResetColor();
OrderInfo order = new()
{
OrderId = message,
Amount = 99.99m // Simulated order amount
};
return ValueTask.FromResult(order);
}
}
/// <summary>
/// Final executor that outputs the completed order summary.
/// </summary>
internal sealed class OrderCompleted() : Executor<OrderInfo, string>("OrderCompleted")
{
public override ValueTask<string> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!");
Console.WriteLine($"│ Payment: {message.PaymentTransactionId}");
Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}");
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
Console.ResetColor();
return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}");
}
}
// Payment sub-workflow executors
/// <summary>
/// Validates payment information for an order.
/// </summary>
internal sealed class ValidatePayment() : Executor<OrderInfo, OrderInfo>("ValidatePayment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Charges the payment for an order.
/// </summary>
internal sealed class ChargePayment() : Executor<OrderInfo, OrderInfo>("ChargePayment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}");
Console.ResetColor();
return message;
}
}
// FraudCheck sub-sub-workflow executors (nested inside Payment)
/// <summary>
/// Analyzes transaction patterns for potential fraud.
/// </summary>
internal sealed class AnalyzePatterns() : Executor<OrderInfo, OrderInfo>("AnalyzePatterns")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
// Store analysis results in shared state for the next executor in this sub-workflow
int patternsFound = new Random().Next(0, 5);
await context.QueueStateUpdateAsync("patternsFound", patternsFound, cancellationToken: cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete ({patternsFound} suspicious patterns)");
Console.ResetColor();
return message;
}
}
/// <summary>
/// Calculates a risk score for the transaction.
/// </summary>
internal sealed class CalculateRiskScore() : Executor<OrderInfo, OrderInfo>("CalculateRiskScore")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
// Read the pattern count from shared state (written by AnalyzePatterns)
int patternsFound = await context.ReadStateAsync<int>("patternsFound", cancellationToken: cancellationToken);
int riskScore = Math.Min(patternsFound * 20 + new Random().Next(1, 20), 100);
// Emit a workflow event from within a nested sub-workflow
await context.AddEventAsync(new FraudRiskAssessedEvent(riskScore), cancellationToken);
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (based on {patternsFound} patterns)");
Console.ResetColor();
return message;
}
}
// Shipping sub-workflow executors
/// <summary>
/// Selects a shipping carrier for an order.
/// </summary>
/// <remarks>
/// This executor uses <see cref="Executor{TInput}"/> (void return) combined with
/// <see cref="IWorkflowContext.SendMessageAsync"/> to forward the order to the next
/// connected executor (CreateShipment). This demonstrates explicit typed message passing
/// as an alternative to returning a value from the handler.
/// </remarks>
internal sealed class SelectCarrier() : Executor<OrderInfo>("SelectCarrier")
{
public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
message.Carrier = message.Amount > 50 ? "Express" : "Standard";
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}");
Console.ResetColor();
// Use SendMessageAsync to forward the updated order to connected executors.
// With a void-return executor, this is the mechanism for passing data downstream.
await context.SendMessageAsync(message, cancellationToken: cancellationToken);
}
}
/// <summary>
/// Creates shipment and generates tracking number.
/// </summary>
internal sealed class CreateShipment() : Executor<OrderInfo, OrderInfo>("CreateShipment")
{
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'...");
Console.ResetColor();
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}";
Console.ForegroundColor = ConsoleColor.Blue;
Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}");
Console.ResetColor();
return message;
}
}
@@ -0,0 +1,146 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates nested sub-workflows. A sub-workflow can act as an executor
// within another workflow, including multi-level nesting (sub-workflow within sub-workflow).
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using SubWorkflows;
// Get DTS connection string from environment variable
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Build the FraudCheck sub-workflow (this will be nested inside the Payment sub-workflow)
AnalyzePatterns analyzePatterns = new();
CalculateRiskScore calculateRiskScore = new();
Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns)
.WithName("SubFraudCheck")
.WithDescription("Analyzes transaction patterns and calculates risk score")
.AddEdge(analyzePatterns, calculateRiskScore)
.Build();
// Build the Payment sub-workflow: ValidatePayment -> FraudCheck (sub-workflow) -> ChargePayment
ValidatePayment validatePayment = new();
ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck");
ChargePayment chargePayment = new();
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
.WithName("SubPaymentProcessing")
.WithDescription("Validates and processes payment for an order")
.AddEdge(validatePayment, fraudCheckExecutor)
.AddEdge(fraudCheckExecutor, chargePayment)
.Build();
// Build the Shipping sub-workflow: SelectCarrier -> CreateShipment
SelectCarrier selectCarrier = new();
CreateShipment createShipment = new();
Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier)
.WithName("SubShippingArrangement")
.WithDescription("Selects carrier and creates shipment")
.AddEdge(selectCarrier, createShipment)
.Build();
// Build the main workflow using sub-workflows as executors
// OrderReceived -> Payment (sub-workflow) -> Shipping (sub-workflow) -> OrderCompleted
OrderReceived orderReceived = new();
OrderCompleted orderCompleted = new();
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping");
Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived)
.WithName("OrderProcessing")
.WithDescription("Processes an order through payment and shipping")
.AddEdge(orderReceived, paymentExecutor)
.AddEdge(paymentExecutor, shippingExecutor)
.AddEdge(shippingExecutor, orderCompleted)
.Build();
// Configure and start the host
// Register only the main workflow - sub-workflows are discovered automatically!
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
workflowOptions => workflowOptions.AddWorkflow(orderProcessingWorkflow),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
Console.WriteLine("Durable Sub-Workflows Sample");
Console.WriteLine("Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted");
Console.WriteLine(" Payment contains nested FraudCheck sub-workflow (Level 2 nesting)");
Console.WriteLine();
Console.WriteLine("Enter an order ID (or 'exit'):");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
{
break;
}
try
{
await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient);
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
Console.WriteLine();
}
await host.StopAsync();
// Start a new workflow using streaming to observe events (including from sub-workflows)
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
{
Console.WriteLine($"\nStarting order processing for '{orderId}'...");
IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId);
Console.WriteLine($"Run ID: {run.RunId}");
Console.WriteLine();
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
// Custom event emitted from the FraudCheck sub-sub-workflow
case FraudRiskAssessedEvent e:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Event from sub-workflow] {e.GetType().Name}: Risk score {e.RiskScore}/100");
Console.ResetColor();
break;
case DurableWorkflowCompletedEvent e:
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"✓ Order completed: {e.Result}");
Console.ResetColor();
break;
case DurableWorkflowFailedEvent e:
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"✗ Failed: {e.ErrorMessage}");
Console.ResetColor();
break;
}
}
}
@@ -0,0 +1,105 @@
# Sub-Workflows Sample (Nested Workflows)
This sample demonstrates how to compose complex workflows from simpler, reusable sub-workflows. Sub-workflows are built using `WorkflowBuilder` and embedded as executors via `BindAsExecutor()`. Unlike the in-process workflow runner, the durable workflow backend persists execution state across process restarts — each sub-workflow runs as a separate orchestration instance on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard.
## Key Concepts Demonstrated
- **Sub-workflows**: Using `Workflow.BindAsExecutor()` to embed a workflow as an executor in another workflow
- **Multi-level nesting**: Sub-workflows within sub-workflows (Level 2 nesting)
- **Automatic discovery**: Registering only the main workflow; sub-workflows are discovered automatically
- **Failure isolation**: Each sub-workflow runs as a separate orchestration instance on the DTS backend
- **Hierarchical visualization**: Parent-child orchestration hierarchy visible in the DTS dashboard
- **Event propagation**: Custom workflow events (`FraudRiskAssessedEvent`) bubble up from nested sub-workflows to the streaming client
- **Message passing**: Using `Executor<TInput>` (void return) with `SendMessageAsync` to forward typed messages to connected executors (`SelectCarrier`)
- **Shared state within sub-workflows**: Using `QueueStateUpdateAsync`/`ReadStateAsync` to share data between executors within a sub-workflow (`AnalyzePatterns``CalculateRiskScore`)
## Overview
The sample implements an order processing workflow composed of two sub-workflows, one of which contains its own nested sub-workflow:
```
OrderProcessing (main workflow)
├── OrderReceived
├── Payment (sub-workflow)
│ ├── ValidatePayment
│ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting!
│ │ ├── AnalyzePatterns
│ │ └── CalculateRiskScore
│ └── ChargePayment
├── Shipping (sub-workflow)
│ ├── SelectCarrier ← Uses SendMessageAsync (void-return executor)
│ └── CreateShipment
└── OrderCompleted
```
| Executor | Sub-Workflow | Description |
|----------|-------------|-------------|
| OrderReceived | Main | Receives order ID and creates order info |
| ValidatePayment | Payment | Validates payment information |
| AnalyzePatterns | FraudCheck (nested in Payment) | Analyzes transaction patterns, stores results in shared state |
| CalculateRiskScore | FraudCheck (nested in Payment) | Reads shared state, calculates risk score, emits `FraudRiskAssessedEvent` |
| ChargePayment | Payment | Charges payment amount |
| SelectCarrier | Shipping | Selects carrier using `SendMessageAsync` (void-return executor) |
| CreateShipment | Shipping | Creates shipment with tracking |
| OrderCompleted | Main | Outputs completed order summary |
## How Sub-Workflows Work
For an introduction to sub-workflows and the `BindAsExecutor()` API, see the [Sub-Workflows foundational sample](../../../../03-workflows/_StartHere/05_SubWorkflows).
This durable sample extends the same pattern — the key difference is that each sub-workflow runs as a **separate orchestration instance** on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard.
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows
dotnet run --framework net10.0
```
### Sample Output
```text
Durable Sub-Workflows Sample
Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted
Payment contains nested FraudCheck sub-workflow (Level 2 nesting)
Enter an order ID (or 'exit'):
> ORD-001
Starting order processing for 'ORD-001'...
Run ID: abc123...
[OrderReceived] Processing order 'ORD-001'
[Payment/ValidatePayment] Validating payment for order 'ORD-001'...
[Payment/ValidatePayment] Payment validated for $99.99
[Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order 'ORD-001'...
[Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete (2 suspicious patterns)
[Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order 'ORD-001'...
[Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: 53/100 (based on 2 patterns)
[Event from sub-workflow] FraudRiskAssessedEvent: Risk score 53/100
[Payment/ChargePayment] Charging $99.99 for order 'ORD-001'...
[Payment/ChargePayment] ✓ Payment processed: TXN-A1B2C3D4
[Shipping/SelectCarrier] Selecting carrier for order 'ORD-001'...
[Shipping/SelectCarrier] ✓ Selected carrier: Express
[Shipping/CreateShipment] Creating shipment for order 'ORD-001'...
[Shipping/CreateShipment] ✓ Shipment created: TRACK-I9J0K1L2M3
┌─────────────────────────────────────────────────────────────────┐
│ [OrderCompleted] Order 'ORD-001' successfully processed!
│ Payment: TXN-A1B2C3D4
│ Shipping: Express - TRACK-I9J0K1L2M3
└─────────────────────────────────────────────────────────────────┘
✓ Order completed: Order ORD-001 completed. Tracking: TRACK-I9J0K1L2M3
> exit
```
### Viewing Workflows in the DTS Dashboard
After running the workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration hierarchy, including sub-orchestrations.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
Because each sub-workflow runs as a separate orchestration instance, the dashboard shows a parent-child hierarchy: the top-level `OrderProcessing` orchestration with `Payment` and `Shipping` as child orchestrations, and `FraudCheck` nested under `Payment`. You can click into each orchestration to inspect its executor inputs/outputs, events, and execution timeline independently.
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowHITL</AssemblyName>
<RootNamespace>WorkflowHITL</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowHITL;
/// <summary>
/// Represents an expense approval request.
/// </summary>
/// <param name="ExpenseId">The unique identifier of the expense.</param>
/// <param name="Amount">The amount of the expense.</param>
/// <param name="EmployeeName">The name of the employee submitting the expense.</param>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
/// <summary>
/// Represents the response to an approval request.
/// </summary>
/// <param name="Approved">Whether the expense was approved.</param>
/// <param name="Comments">Optional comments from the approver.</param>
public record ApprovalResponse(bool Approved, string? Comments);
/// <summary>
/// Retrieves expense details and creates an approval request.
/// </summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
/// <inheritdoc/>
public override ValueTask<ApprovalRequest> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// In a real scenario, this would look up expense details from a database
return new ValueTask<ApprovalRequest>(new ApprovalRequest(message, 1500.00m, "Jerry"));
}
}
/// <summary>
/// Prepares the approval request for finance review after manager approval.
/// </summary>
internal sealed class PrepareFinanceReview() : Executor<ApprovalResponse, ApprovalRequest>("PrepareFinanceReview")
{
/// <inheritdoc/>
public override ValueTask<ApprovalRequest> HandleAsync(
ApprovalResponse message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
if (!message.Approved)
{
throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense.");
}
// In a real scenario, this would retrieve the original expense details
return new ValueTask<ApprovalRequest>(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry"));
}
}
/// <summary>
/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance.
/// </summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
{
/// <inheritdoc/>
public override async ValueTask<string> HandleAsync(
ApprovalResponse[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Check that all parallel approvals passed
ApprovalResponse? denied = Array.Find(message, r => !r.Approved);
if (denied is not null)
{
return $"Expense reimbursement denied. Comments: {denied.Comments}";
}
// Simulate payment processing
await Task.Delay(1000, cancellationToken);
return $"Expense reimbursed at {DateTime.UtcNow:O}";
}
}
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks.
//
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
// │ ├─►│ExpenseReimburse │
// │ ┌────────────────────┐ │ └─────────────────┘
// └►│ComplianceApproval │──┘
// │ (RequestPort) │
// └────────────────────┘
//
// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance.
// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WorkflowHITL;
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors and RequestPorts for the three HITL pause points
CreateApprovalRequest createRequest = new();
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
PrepareFinanceReview prepareFinanceReview = new();
RequestPort<ApprovalRequest, ApprovalResponse> budgetApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("BudgetApproval");
RequestPort<ApprovalRequest, ApprovalResponse> complianceApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ComplianceApproval");
ExpenseReimburse reimburse = new();
// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse
Workflow expenseApproval = new WorkflowBuilder(createRequest)
.WithName("ExpenseReimbursement")
.WithDescription("Expense reimbursement with manager and parallel finance approvals")
.AddEdge(createRequest, managerApproval)
.AddEdge(managerApproval, prepareFinanceReview)
.AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval])
.AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse)
.Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
options => options.AddWorkflow(expenseApproval),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
// Start the workflow with streaming to observe events including HITL pauses
string expenseId = "EXP-2025-001";
Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}");
IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId);
Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n");
// Watch for workflow events — handle HITL requests as they arrive
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableWorkflowWaitingForInputEvent requestEvent:
Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}");
Console.WriteLine($" Input: {requestEvent.Input}");
// In a real scenario, this would involve human interaction (UI, email, Teams, etc.)
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}");
ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager.");
await run.SendResponseAsync(requestEvent, approvalResponse);
Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n");
break;
case DurableWorkflowCompletedEvent completedEvent:
Console.WriteLine($"Workflow completed: {completedEvent.Result}");
break;
case DurableWorkflowFailedEvent failedEvent:
Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}");
break;
}
}
await host.StopAsync();
@@ -0,0 +1,106 @@
# Workflow Human-in-the-Loop (HITL) Sample
This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming.
## Key Concepts Demonstrated
- Using `RequestPort` to define external input points in a workflow
- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in
- Streaming workflow events with `IStreamingWorkflowRun`
- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses
- Using `SendResponseAsync` to provide responses and resume the workflow
- **Durability**: The workflow survives process restarts while waiting for human input
## Workflow
This sample implements the following workflow:
```
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
│ ├─►│ExpenseReimburse │
│ ┌────────────────────┐ │ └─────────────────┘
└►│ComplianceApproval │──┘
│ (RequestPort) │
└────────────────────┘
```
| Step | Description |
|------|-------------|
| CreateApprovalRequest | Retrieves expense details and creates an approval request |
| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval |
| PrepareFinanceReview | Prepares the request for finance review after manager approval |
| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) |
| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) |
| ExpenseReimburse | Processes the reimbursement after all approvals pass |
## How It Works
A `RequestPort` defines a typed external input point in the workflow:
```csharp
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval =
RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
```
Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow:
```csharp
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableWorkflowWaitingForInputEvent requestEvent:
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved."));
break;
}
}
```
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL
dotnet run --framework net10.0
```
### Sample Output
```text
Starting expense reimbursement workflow for expense: EXP-2025-001
Workflow started with instance ID: abc123...
Workflow paused at RequestPort: ManagerApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow paused at RequestPort: BudgetApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow paused at RequestPort: ComplianceApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z
```
### Viewing Workflows in the DTS Dashboard
After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`).
2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals.
3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received.
@@ -0,0 +1,5 @@
<Project> <Import Project="../../Directory.Build.props" /> <!-- Remove the Environment alias from parent Directory.Build.props to allow System.Environment usage -->
<ItemGroup>
<Using Remove="SampleHelpers.SampleEnvironment" />
</ItemGroup>
</Project>
@@ -0,0 +1,50 @@
# Durable Workflow Samples
This directory contains samples demonstrating how to build durable workflows using the Microsoft Agent Framework.
## Environment Setup
### Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) or later
- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure
### Running the Durable Task Scheduler Emulator
To run the emulator locally using Docker:
```bash
docker run -d -p 8080:8080 --name durabletask-emulator mcr.microsoft.com/durabletask/emulator:latest
```
Set the connection string environment variable to point to the local emulator:
```bash
# Linux/macOS
export DURABLE_TASK_SCHEDULER_CONNECTION_STRING="AccountEndpoint=http://localhost:8080"
# Windows (PowerShell)
$env:DURABLE_TASK_SCHEDULER_CONNECTION_STRING = "AccountEndpoint=http://localhost:8080"
```
## Samples
### Console Apps
| Sample | Description |
|--------|-------------|
| [01_SequentialWorkflow](ConsoleApps/01_SequentialWorkflow/) | Basic sequential workflow with ordered executor steps |
| [02_ConcurrentWorkflow](ConsoleApps/02_ConcurrentWorkflow/) | Fan-out/fan-in concurrent workflow execution |
| [03_ConditionalEdges](ConsoleApps/03_ConditionalEdges/) | Workflows with conditional routing between executors |
| [05_WorkflowEvents](ConsoleApps/05_WorkflowEvents/) | Publishing and subscribing to workflow events |
| [06_WorkflowSharedState](ConsoleApps/06_WorkflowSharedState/) | Sharing state across workflow executors |
| [07_SubWorkflows](ConsoleApps/07_SubWorkflows/) | Nested sub-workflow composition |
| [08_WorkflowHITL](ConsoleApps/08_WorkflowHITL/) | Human-in-the-loop workflow with approval gates |
### Azure Functions
| Sample | Description |
|--------|-------------|
| [01_SequentialWorkflow](AzureFunctions/01_SequentialWorkflow/) | Sequential workflow hosted in Azure Functions |
| [02_ConcurrentWorkflow](AzureFunctions/02_ConcurrentWorkflow/) | Concurrent workflow hosted in Azure Functions |
| [03_WorkflowHITL](AzureFunctions/03_WorkflowHITL/) | Human-in-the-loop workflow hosted in Azure Functions |
@@ -75,10 +75,15 @@ string apiKey = builder.Configuration["OPENAI_API_KEY"]
?? throw new InvalidOperationException("Set the OPENAI_API_KEY environment variable.");
string model = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4.1-mini";
// Here we are using Singleton lifetime, since none of the services, function tools and user context classes in the sample have state that are per request.
// You should evaluate the appropriate lifetime for your own services and tools based on their behavior and dependencies.
// E.g. if any of the service instances or tools maintain state that is specific to a user, and each request may be from a different user,
// you should use Scoped lifetime instead, so that a new instance is created for each request.
// Note that if you use Scoped lifetime for any dependencies, you must also use Scoped lifetime for any class that uses it, including the agent itself.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<IUserContext, KeycloakUserContext>();
builder.Services.AddScoped<ExpenseService>();
builder.Services.AddScoped<AIAgent>(sp =>
builder.Services.AddSingleton<IUserContext, KeycloakUserContext>();
builder.Services.AddSingleton<ExpenseService>();
builder.Services.AddSingleton<AIAgent>(sp =>
{
var expenseService = sp.GetRequiredService<ExpenseService>();
@@ -27,43 +27,73 @@ public interface IUserContext
/// Keycloak uses <c>sub</c> for the user ID, <c>preferred_username</c>
/// for the login name, <c>given_name</c>/<c>family_name</c> for the
/// display name, and <c>scope</c> (space-delimited) for granted scopes.
/// Registered as a scoped service so it is resolved once per request.
/// Registered as a singleton — claims are parsed once per request and
/// cached in <see cref="HttpContext.Items"/>.
/// </summary>
public sealed class KeycloakUserContext : IUserContext
{
public string UserId { get; }
private static readonly object s_cacheKey = new();
public string UserName { get; }
public string DisplayName { get; }
public IReadOnlySet<string> Scopes { get; }
private readonly IHttpContextAccessor _httpContextAccessor;
public KeycloakUserContext(IHttpContextAccessor httpContextAccessor)
{
ClaimsPrincipal? user = httpContextAccessor.HttpContext?.User;
this._httpContextAccessor = httpContextAccessor;
}
this.UserId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
public string UserId => this.GetOrCreateCachedInfo().UserId;
this.UserName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
public string UserName => this.GetOrCreateCachedInfo().UserName;
public string DisplayName => this.GetOrCreateCachedInfo().DisplayName;
public IReadOnlySet<string> Scopes => this.GetOrCreateCachedInfo().Scopes;
private CachedUserInfo GetOrCreateCachedInfo()
{
HttpContext? httpContext = this._httpContextAccessor.HttpContext;
if (httpContext is not null && httpContext.Items.TryGetValue(s_cacheKey, out object? cached) && cached is CachedUserInfo info)
{
return info;
}
info = ParseClaims(httpContext?.User);
if (httpContext is not null)
{
httpContext.Items[s_cacheKey] = info;
}
return info;
}
private static CachedUserInfo ParseClaims(ClaimsPrincipal? user)
{
string userId = user?.FindFirstValue(ClaimTypes.NameIdentifier)
?? user?.FindFirstValue("sub")
?? "anonymous";
string userName = user?.FindFirstValue("preferred_username")
?? user?.FindFirstValue(ClaimTypes.Name)
?? "unknown";
string? givenName = user?.FindFirstValue("given_name") ?? user?.FindFirstValue(ClaimTypes.GivenName);
string? familyName = user?.FindFirstValue("family_name") ?? user?.FindFirstValue(ClaimTypes.Surname);
this.DisplayName = (givenName, familyName) switch
string displayName = (givenName, familyName) switch
{
(not null, not null) => $"{givenName} {familyName}",
(not null, null) => givenName,
(null, not null) => familyName,
_ => this.UserName,
_ => userName,
};
string? scopeClaim = user?.FindFirstValue("scope");
this.Scopes = scopeClaim is not null
IReadOnlySet<string> scopes = scopeClaim is not null
? new HashSet<string>(scopeClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase)
: new HashSet<string>(StringComparer.OrdinalIgnoreCase);
return new CachedUserInfo(userId, userName, displayName, scopes);
}
private sealed record CachedUserInfo(string UserId, string UserName, string DisplayName, IReadOnlySet<string> Scopes);
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -36,11 +36,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,9 +11,10 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
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";
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";
[Description("Get the weather for a given location.")]
static string GetWeather([Description("The location to get the weather for.")] string location)
@@ -22,17 +23,19 @@ static string GetWeather([Description("The location to get the weather for.")] s
// Create the chat client and agent.
// Note: ApprovalRequiredAIFunction wraps the tool to require user approval before invocation.
// User should reply with 'approve' or 'reject' when prompted.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
#pragma warning disable MEAI001 // Type is for evaluation purposes only
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new AzureCliCredential())
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.CreateAIAgent(
.AsAIAgent(
instructions: "You are a helpful assistant",
tools: [new ApprovalRequiredAIFunction(AIFunctionFactory.Create(GetWeather))]
);
#pragma warning restore MEAI001
var threadRepository = new InMemoryAgentThreadRepository(agent);
InMemoryAgentThreadRepository threadRepository = new(agent);
await agent.RunAIAgentAsync(telemetrySourceName: "Agents", threadRepository: threadRepository);
@@ -35,10 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.6" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,9 +9,10 @@ using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Responses;
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";
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";
// Create an MCP tool that can be called without approval.
AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAddress: "https://learn.microsoft.com/api/mcp")
@@ -28,8 +29,7 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetResponsesClient(deploymentName)
.AsIChatClient()
.CreateAIAgent(
.AsAIAgent(
instructions: "You answer questions by searching the Microsoft Learn content only.",
name: "MicrosoftLearnAgent",
tools: [mcpTool]);
@@ -18,7 +18,7 @@ Before running this sample, ensure you have:
2. A deployment of a chat model (e.g., gpt-4o-mini)
3. Azure CLI installed and authenticated
**Note**: This sample uses Azure CLI credentials for authentication. Make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
**Note**: This sample uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, make sure you're logged in with `az login` and have access to the Azure OpenAI resource.
## Environment Variables
@@ -36,11 +36,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -15,21 +15,21 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
string deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
var seattleHotels = new[]
{
Hotel[] seattleHotels =
[
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
};
];
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
@@ -54,21 +54,21 @@ string GetAvailableHotels(
return "Error: Check-out date must be after check-in date.";
}
var nights = (checkOut - checkIn).Days;
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
int nights = (checkOut - checkIn).Days;
List<Hotel> availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
var result = new StringBuilder();
StringBuilder result = new();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (var hotel in availableHotels)
foreach (Hotel hotel in availableHotels)
{
var totalCost = hotel.PricePerNight * nights;
int totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
@@ -84,7 +84,10 @@ string GetAvailableHotels(
}
}
var credential = new AzureCliCredential();
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
AIProjectClient projectClient = new(new Uri(endpoint), credential);
ClientConnection connection = projectClient.GetConnection(typeof(AzureOpenAIClient).FullName!);
@@ -96,14 +99,14 @@ if (!connection.TryGetLocatorAsUri(out Uri? openAiEndpoint) || openAiEndpoint is
openAiEndpoint = new Uri($"https://{openAiEndpoint.Host}");
Console.WriteLine($"OpenAI Endpoint: {openAiEndpoint}");
var chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
IChatClient chatClient = new AzureOpenAIClient(openAiEndpoint, credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.UseOpenTelemetry(sourceName: "Agents", configure: cfg => cfg.EnableSensitiveData = false)
.Build();
var agent = new ChatClientAgent(chatClient,
AIAgent agent = chatClient.AsAIAgent(
name: "SeattleHotelAgent",
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.1-preview.1.25612.2" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -11,8 +11,8 @@ using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI.Chat;
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";
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";
TextSearchProviderOptions textSearchOptions = new()
{
@@ -28,13 +28,13 @@ AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.CreateAIAgent(new ChatClientAgentOptions
.AsAIAgent(new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
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 => new TextSearchProvider(MockSearchAsync, ctx.SerializedState, ctx.JsonSerializerOptions, textSearchOptions)
AIContextProviders = [new TextSearchProvider(MockSearchAsync, textSearchOptions)]
});
await agent.RunAIAgentAsync();
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.8.0-beta.1" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.3.0" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -9,13 +9,16 @@ using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var openAiEndpoint = 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 toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
string openAiEndpoint = 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";
string toolConnectionId = Environment.GetEnvironmentVariable("MCP_TOOL_CONNECTION_ID") ?? throw new InvalidOperationException("MCP_TOOL_CONNECTION_ID is not set.");
var credential = new AzureCliCredential();
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
DefaultAzureCredential credential = new();
var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
IChatClient chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
@@ -23,7 +26,7 @@ var chatClient = new AzureOpenAIClient(new Uri(openAiEndpoint), credential)
.UseOpenTelemetry(sourceName: "Agents", configure: (cfg) => cfg.EnableSensitiveData = true)
.Build();
var agent = new ChatClientAgent(chatClient,
AIAgent agent = chatClient.AsAIAgent(
name: "AgentWithTools",
instructions: @"You are a helpful assistant with access to tools for fetching Microsoft documentation.
@@ -6,7 +6,7 @@ Key features:
- Configuring Foundry tools using `UseFoundryTools` with MCP and code interpreter
- Connecting to an external MCP tool via a Foundry project connection
- Using `AzureCliCredential` for Azure authentication
- Using `DefaultAzureCredential` for Azure authentication
- OpenTelemetry instrumentation for both the chat client and agent
> For common prerequisites and setup instructions, see the [Hosted Agent Samples README](../README.md).
@@ -36,7 +36,7 @@ $env:MCP_TOOL_CONNECTION_ID="SampleMCPTool"
## How It Works
1. An `AzureOpenAIClient` is created with `AzureCliCredential` and used to get a chat client
1. An `AzureOpenAIClient` is created with `DefaultAzureCredential` and used to get a chat client
2. The chat client is wrapped with `UseFoundryTools` which registers two Foundry tool types:
- **MCP connection**: Connects to an external MCP server (Microsoft Learn) via the project connection name, providing documentation fetch and search capabilities
- **Code interpreter**: Allows the agent to execute code snippets when needed
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -35,11 +35,10 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.5" />
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.9" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.7.0-beta.2" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.1.0-preview.1.25608.1" />
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
@@ -12,8 +12,8 @@ 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";
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";
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
@@ -32,9 +32,9 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
.AddEdge(frenchAgent, spanishAgent)
.AddEdge(spanishAgent, englishAgent)
.Build()
.AsAgent();
.AsAIAgent();
await agent.RunAIAgentAsync();
static ChatClientAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
new(chatClient, $"You are a translation assistant that translates the provided text to {targetLanguage}.");
static AIAgent GetTranslationAgent(string targetLanguage, IChatClient chatClient) =>
chatClient.AsAIAgent($"You are a translation assistant that translates the provided text to {targetLanguage}.");
@@ -19,7 +19,7 @@ Before you begin, ensure you have the following prerequisites:
- 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).
**Note**: This demo uses `DefaultAzureCredential` for authentication, which probes multiple sources automatically. For local development, 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,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "FoundryMultiAgent.dll"]
@@ -0,0 +1,76 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.AzureAI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-preview.251219.1" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents
// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder.
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Using Azure AI endpoint: {endpoint}");
Console.WriteLine($"Using model deployment: {deploymentName}");
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create Foundry agents
AIAgent writerAgent = await aiProjectClient.CreateAIAgentAsync(
name: "Writer",
model: deploymentName,
instructions: "You are an excellent content writer. You create new content and edit contents based on the feedback.");
AIAgent reviewerAgent = await aiProjectClient.CreateAIAgentAsync(
name: "Reviewer",
model: deploymentName,
instructions: "You are an excellent content reviewer. Provide actionable feedback to the writer about the provided content. Provide the feedback in the most concise manner possible.");
try
{
var workflow = new WorkflowBuilder(writerAgent)
.AddEdge(writerAgent, reviewerAgent)
.Build();
Console.WriteLine("Starting Writer-Reviewer Workflow Agent Server on http://localhost:8088");
await workflow.AsAgent().RunAIAgentAsync();
}
finally
{
// Cleanup server-side agents
await aiProjectClient.Agents.DeleteAgentAsync(writerAgent.Name);
await aiProjectClient.Agents.DeleteAgentAsync(reviewerAgent.Name);
}
@@ -0,0 +1,168 @@
**IMPORTANT!** All samples and other resources made available in this GitHub repository ("samples") are designed to assist in accelerating development of agents, solutions, and agent workflows for various scenarios. Review all provided resources and carefully test output behavior in the context of your use case. AI responses may be inaccurate and AI actions should be monitored with human oversight. Learn more in the transparency documents for [Agent Service](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/agents/transparency-note) and [Agent Framework](https://github.com/microsoft/agent-framework/blob/main/TRANSPARENCY_FAQ.md).
Agents, solutions, or other output you create may be subject to legal and regulatory requirements, may require licenses, or may not be suitable for all industries, scenarios, or use cases. By using any sample, you are acknowledging that any output created using those samples are solely your responsibility, and that you will comply with all applicable laws, regulations, and relevant safety standards, terms of service, and codes of conduct.
Third-party samples contained in this folder are subject to their own designated terms, and they have not been tested or verified by Microsoft or its affiliates.
Microsoft has no responsibility to you or others with respect to any of these samples or any resulting output.
# What this sample demonstrates
This sample demonstrates a **key advantage of code-based hosted agents**:
- **Multi-agent workflows** - Orchestrate multiple agents working together
Code-based agents can execute **any C# code** you write. This sample includes a Writer-Reviewer workflow where two agents collaborate: a Writer creates content and a Reviewer provides feedback.
The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/) and can be deployed to Microsoft Foundry.
## How It Works
### Multi-Agent Workflow
In [Program.cs](Program.cs), the sample creates two agents using `AIProjectClient.CreateAIAgentAsync()` from the [Microsoft.Agents.AI.AzureAI](https://www.nuget.org/packages/Microsoft.Agents.AI.AzureAI/) package:
- **Writer** - An agent that creates and edits content based on feedback
- **Reviewer** - An agent that provides actionable feedback on the content
The `WorkflowBuilder` from the [Microsoft.Agents.AI.Workflows](https://www.nuget.org/packages/Microsoft.Agents.AI.Workflows/) package connects these agents in a sequential flow:
1. The Writer receives the initial request and generates content
2. The Reviewer evaluates the content and provides feedback
3. Both agent responses are output to the user
### Agent Hosting
The agent is hosted using the [Azure AI AgentServer SDK](https://www.nuget.org/packages/Azure.AI.AgentServer.AgentFramework/),
which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
## Running the Agent Locally
### Prerequisites
Before running this sample, ensure you have:
1. **Azure AI Foundry Project**
- Project created.
- Chat model deployed (e.g., `gpt-4o` or `gpt-4.1`)
- Note your project endpoint URL and model deployment name
> **Note**: You can right-click the project in the Microsoft Foundry VS Code extension and select `Copy Project Endpoint URL` to get the endpoint.
2. **Azure CLI**
- Installed and authenticated
- Run `az login` and verify with `az account show`
- Your identity needs the **Azure AI Developer** role on the Foundry resource (for `agents/write` data action required by `CreateAIAgentAsync`)
3. **.NET 10.0 SDK or later**
- Verify your version: `dotnet --version`
- Download from [https://dotnet.microsoft.com/download](https://dotnet.microsoft.com/download)
### Environment Variables
Set the following environment variables:
**PowerShell:**
```powershell
# Replace with your actual values
$env:AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
$env:MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
**Bash:**
```bash
export AZURE_AI_PROJECT_ENDPOINT="https://<your-resource>.services.ai.azure.com/api/projects/<your-project>"
export MODEL_DEPLOYMENT_NAME="gpt-4o-mini"
```
### Running the Sample
To run the agent, execute the following command in your terminal:
```bash
dotnet restore
dotnet build
dotnet run
```
This will start the hosted agent locally on `http://localhost:8088/`.
### Interacting with the Agent
**VS Code:**
1. Open the Visual Studio Code Command Palette and execute the `Microsoft Foundry: Open Container Agent Playground Locally` command.
2. Execute the following commands to start the containerized hosted agent.
```bash
dotnet restore
dotnet build
dotnet run
```
3. Submit a request to the agent through the playground interface. For example, you may enter a prompt such as: "Create a slogan for a new electric SUV that is affordable and fun to drive."
4. Review the agent's response in the playground interface.
> **Note**: Open the local playground before starting the container agent to ensure the visualization functions correctly.
**PowerShell (Windows):**
```powershell
$body = @{
input = "Create a slogan for a new electric SUV that is affordable and fun to drive"
stream = $false
} | ConvertTo-Json
Invoke-RestMethod -Uri http://localhost:8088/responses -Method Post -Body $body -ContentType "application/json"
```
**Bash/curl (Linux/macOS):**
```bash
curl -sS -H "Content-Type: application/json" -X POST http://localhost:8088/responses \
-d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive","stream":false}'
```
You can also use the `run-requests.http` file in this directory with the VS Code REST Client extension.
The Writer agent will generate content based on your prompt, and the Reviewer agent will provide feedback on the output.
## Deploying the Agent to Microsoft Foundry
**Preparation (required)**
Please check the environment_variables section in [agent.yaml](agent.yaml) and ensure the variables there are set in your target Microsoft Foundry Project.
To deploy the hosted agent:
1. Open the VS Code Command Palette and run the `Microsoft Foundry: Deploy Hosted Agent` command.
2. Follow the interactive deployment prompts. The extension will help you select or create the container files it needs.
3. After deployment completes, the hosted agent appears under the `Hosted Agents (Preview)` section of the extension tree. You can select the agent there to view details and test it using the integrated playground.
**What the deploy flow does for you:**
- Creates or obtains an Azure Container Registry for the target project.
- Builds and pushes a container image from your workspace (the build packages the workspace respecting `.dockerignore`).
- Creates an agent version in Microsoft Foundry using the built image. If a `.env` file exists at the workspace root, the extension will parse it and include its key/value pairs as the hosted agent's environment variables in the create request (these variables will be available to the agent runtime).
- Starts the agent container on the project's capability host. If the capability host is not provisioned, the extension will prompt you to enable it and will guide you through creating it.
## MSI Configuration in the Azure Portal
This sample requires the Microsoft Foundry Project to authenticate using a Managed Identity when running remotely in Azure. Grant the project's managed identity the required permissions by assigning the built-in [Azure AI User](https://aka.ms/foundry-ext-project-role) role.
To configure the Managed Identity:
1. In the Azure Portal, open the Foundry Project.
2. Select "Access control (IAM)" from the left-hand menu.
3. Click "Add" and choose "Add role assignment".
4. In the role selection, search for and select "Azure AI User", then click "Next".
5. For "Assign access to", choose "Managed identity".
6. Click "Select members", locate the managed identity associated with your Foundry Project (you can search by the project name), then click "Select".
7. Click "Review + assign" to complete the assignment.
8. Allow a few minutes for the role assignment to propagate before running the application.
## Additional Resources
- [Microsoft Agents Framework](https://learn.microsoft.com/en-us/agent-framework/overview/agent-framework-overview)
- [Managed Identities for Azure Resources](https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/)
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
name: FoundryMultiAgent
displayName: "Foundry Multi-Agent Workflow"
description: >
A multi-agent workflow featuring a Writer and Reviewer that collaborate
to create and refine content using Azure AI Foundry PersistentAgentsClient.
metadata:
authors:
- Microsoft Agent Framework Team
tags:
- Azure AI AgentServer
- Microsoft Agent Framework
- Multi-Agent Workflow
- Writer-Reviewer
- Content Creation
template:
kind: hosted
name: FoundryMultiAgent
protocols:
- protocol: responses
version: v1
environment_variables:
- name: AZURE_AI_PROJECT_ENDPOINT
value: ${AZURE_AI_PROJECT_ENDPOINT}
- name: MODEL_DEPLOYMENT_NAME
value: gpt-4o-mini
resources:
- name: "gpt-4o-mini"
kind: model
id: gpt-4o-mini
@@ -0,0 +1,4 @@
{
"AZURE_AI_PROJECT_ENDPOINT": "https://<your-resource>.services.ai.azure.com/api/projects/<your-project>",
"MODEL_DEPLOYMENT_NAME": "gpt-4o-mini"
}
@@ -0,0 +1,34 @@
@host = http://localhost:8088
@endpoint = {{host}}/responses
### Health Check
GET {{host}}/readiness
### Simple string input - Content creation request
POST {{endpoint}}
Content-Type: application/json
{
"input": "Create a slogan for a new electric SUV that is affordable and fun to drive",
"stream": false
}
### Explicit input format
POST {{endpoint}}
Content-Type: application/json
{
"input": [
{
"type": "message",
"role": "user",
"content": [
{
"type": "input_text",
"text": "Write a short product description for a smart water bottle that tracks hydration"
}
]
}
],
"stream": false
}
@@ -0,0 +1,20 @@
# Build the application
FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine AS build
WORKDIR /src
# Copy files from the current directory on the host to the working directory in the container
COPY . .
RUN dotnet restore
RUN dotnet build -c Release --no-restore
RUN dotnet publish -c Release --no-build -o /app -f net10.0
# Run the application
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
# Copy everything needed to run the app from the "build" stage.
COPY --from=build /app .
EXPOSE 8088
ENTRYPOINT ["dotnet", "FoundrySingleAgent.dll"]
@@ -0,0 +1,67 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!--
Disable central package management for this project.
This project requires explicit package references with versions specified inline rather than
inheriting them from Directory.Packages.props. This is necessary because a Docker image will
be created from this project, and the Docker build process only has access to this folder
and cannot access parent folders where Directory.Packages.props resides.
-->
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
<!--
Remove analyzer PackageReference items inherited from Directory.Packages.props.
Note: ManagePackageVersionsCentrally only controls PackageVersion items, not PackageReference items.
Directory.Packages.props contains both PackageVersion and PackageReference entries for analyzers,
and the PackageReference items are always inherited through MSBuild imports regardless of the
ManagePackageVersionsCentrally setting. We must explicitly remove them before adding our own versions.
-->
<ItemGroup>
<PackageReference Remove="Microsoft.CodeAnalysis.NetAnalyzers" />
<PackageReference Remove="Microsoft.VisualStudio.Threading.Analyzers" />
<PackageReference Remove="xunit.analyzers" />
<PackageReference Remove="Moq.Analyzers" />
<PackageReference Remove="Roslynator.Analyzers" />
<PackageReference Remove="Roslynator.CodeAnalysis.Analyzers" />
<PackageReference Remove="Roslynator.Formatting.Analyzers" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.AgentFramework" Version="1.0.0-beta.8" />
<PackageReference Include="Azure.AI.Projects" Version="1.2.0-beta.5" />
<PackageReference Include="Azure.Identity" Version="1.17.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-preview.251219.1" />
<PackageReference Include="Microsoft.Agents.AI.AzureAI" Version="1.0.0-preview.251219.1" />
</ItemGroup>
<!-- Add analyzers with compatible versions -->
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.CodeAnalysis.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Roslynator.Formatting.Analyzers" Version="4.14.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
</Project>
@@ -0,0 +1,128 @@
// Copyright (c) Microsoft. All rights reserved.
// Seattle Hotel Agent - A simple agent with a tool to find hotels in Seattle.
// Uses Microsoft Agent Framework with Azure AI Foundry.
// Ready for deployment to Foundry Hosted Agent service.
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
// Get configuration from environment variables
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
Console.WriteLine($"Project Endpoint: {endpoint}");
Console.WriteLine($"Model Deployment: {deploymentName}");
// Simulated hotel data for Seattle
var seattleHotels = new[]
{
new Hotel("Contoso Suites", 189, 4.5, "Downtown"),
new Hotel("Fabrikam Residences", 159, 4.2, "Pike Place Market"),
new Hotel("Alpine Ski House", 249, 4.7, "Seattle Center"),
new Hotel("Margie's Travel Lodge", 219, 4.4, "Waterfront"),
new Hotel("Northwind Inn", 139, 4.0, "Capitol Hill"),
new Hotel("Relecloud Hotel", 99, 3.8, "University District"),
};
[Description("Get available hotels in Seattle for the specified dates. This simulates a call to a hotel availability API.")]
string GetAvailableHotels(
[Description("Check-in date in YYYY-MM-DD format")] string checkInDate,
[Description("Check-out date in YYYY-MM-DD format")] string checkOutDate,
[Description("Maximum price per night in USD (optional, defaults to 500)")] int maxPrice = 500)
{
try
{
// Parse dates
if (!DateTime.TryParseExact(checkInDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkIn))
{
return "Error parsing check-in date. Please use YYYY-MM-DD format.";
}
if (!DateTime.TryParseExact(checkOutDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var checkOut))
{
return "Error parsing check-out date. Please use YYYY-MM-DD format.";
}
// Validate dates
if (checkOut <= checkIn)
{
return "Error: Check-out date must be after check-in date.";
}
var nights = (checkOut - checkIn).Days;
// Filter hotels by price
var availableHotels = seattleHotels.Where(h => h.PricePerNight <= maxPrice).ToList();
if (availableHotels.Count == 0)
{
return $"No hotels found in Seattle within your budget of ${maxPrice}/night.";
}
// Build response
var result = new StringBuilder();
result.AppendLine($"Available hotels in Seattle from {checkInDate} to {checkOutDate} ({nights} nights):");
result.AppendLine();
foreach (var hotel in availableHotels)
{
var totalCost = hotel.PricePerNight * nights;
result.AppendLine($"**{hotel.Name}**");
result.AppendLine($" Location: {hotel.Location}");
result.AppendLine($" Rating: {hotel.Rating}/5");
result.AppendLine($" ${hotel.PricePerNight}/night (Total: ${totalCost})");
result.AppendLine();
}
return result.ToString();
}
catch (Exception ex)
{
return $"Error processing request. Details: {ex.Message}";
}
}
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential());
// Create Foundry agent with hotel search tool
AIAgent agent = await aiProjectClient.CreateAIAgentAsync(
name: "SeattleHotelAgent",
model: deploymentName,
instructions: """
You are a helpful travel assistant specializing in finding hotels in Seattle, Washington.
When a user asks about hotels in Seattle:
1. Ask for their check-in and check-out dates if not provided
2. Ask about their budget preferences if not mentioned
3. Use the GetAvailableHotels tool to find available options
4. Present the results in a friendly, informative way
5. Offer to help with additional questions about the hotels or Seattle
Be conversational and helpful. If users ask about things outside of Seattle hotels,
politely let them know you specialize in Seattle hotel recommendations.
""",
tools: [AIFunctionFactory.Create(GetAvailableHotels)]);
try
{
Console.WriteLine("Seattle Hotel Agent Server running on http://localhost:8088");
await agent.RunAIAgentAsync(telemetrySourceName: "Agents");
}
finally
{
// Cleanup server-side agent
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
}
// Hotel record for simulated data
internal sealed record Hotel(string Name, int PricePerNight, double Rating, string Location);

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