Compare commits

..
Author SHA1 Message Date
copilot-swe-agent[bot]andcrickman 071456384e Fix: preserve function call/tool result messages in session when using ChatReducer with AfterMessageAdded trigger
Co-authored-by: crickman <66376200+crickman@users.noreply.github.com>
2026-03-17 21:34:22 +00:00
copilot-swe-agent[bot] 401f160719 Initial plan 2026-03-17 21:08:23 +00:00
Shyju KrishnankuttyandGitHub 7c85f98c27 .NET: Align sample build configuration with test runner in CI (#4735)
* Run azure functions integration tests in release mode.

* Use debug when in debug build.
2026-03-17 20:20:14 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1e6f8909ec Bump pyjwt from 2.11.0 to 2.12.0 in /python (#4699)
Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.11.0 to 2.12.0.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.11.0...2.12.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.12.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:06:07 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
008fe23585 Bump actions/upload-artifact from 4 to 7 (#4373)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:05:55 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6af0511e2b Bump MishaKav/pytest-coverage-comment from 1.2.0 to 1.6.0 (#4543)
Bumps [MishaKav/pytest-coverage-comment](https://github.com/mishakav/pytest-coverage-comment) from 1.2.0 to 1.6.0.
- [Release notes](https://github.com/mishakav/pytest-coverage-comment/releases)
- [Changelog](https://github.com/MishaKav/pytest-coverage-comment/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mishakav/pytest-coverage-comment/compare/v1.2.0...v1.6.0)

---
updated-dependencies:
- dependency-name: MishaKav/pytest-coverage-comment
  dependency-version: 1.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:37 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6dbb0a5bb4 Bump danielpalme/ReportGenerator-GitHub-Action from 5.5.1 to 5.5.3 (#4542)
Bumps [danielpalme/ReportGenerator-GitHub-Action](https://github.com/danielpalme/reportgenerator-github-action) from 5.5.1 to 5.5.3.
- [Release notes](https://github.com/danielpalme/reportgenerator-github-action/releases)
- [Commits](https://github.com/danielpalme/reportgenerator-github-action/compare/5.5.1...5.5.3)

---
updated-dependencies:
- dependency-name: danielpalme/ReportGenerator-GitHub-Action
  dependency-version: 5.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
21af304c7d Bump actions/setup-dotnet from 5.1.0 to 5.2.0 (#4541)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5.1.0...v5.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 16:04:07 +00:00
94af83680e Python: Fix RUN_FINISHED.interrupt to accumulate all interrupts when multiple tools need approval (#4717)
* Fix flow.interrupts overwrite when multiple tools need approval (#4590)

Change flow.interrupts assignment to append so that all interrupt entries
accumulate when multiple tools require approval in a single turn.

Both _run_common.py and _agent_run.py used assignment (=) which caused
each new interrupt to overwrite the previous one. Switching to append()
ensures RUN_FINISHED.interrupt contains all pending approvals.

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

* Add test for streaming path with multiple confirm_changes interrupts (#4590)

Add integration test exercising run_agent_stream with multiple predictive
tool calls requiring confirmation. Verifies that flow.interrupts.append()
correctly accumulates all interrupt entries and they appear in the
RUN_FINISHED event.

Also confirms FlowState already declares interrupts field with
default_factory=list, addressing the AttributeError concern from review.

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

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 12:44:44 +00:00
cdb51e6a41 Python: fix thread serialization for multi-turn tool calls (#4684)
* Python: strip fc_id from loaded history

* Move fc_id replay handling into Responses client

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

* Remove unnecessary pytest asyncio marker

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

* Add Responses integration test for fc_id replay

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

* removed old arg

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-17 10:00:04 +00:00
cbcdb2d29e .NET: Add durable workflow support (#4436)
* .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.

* .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.

* .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>

* .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.

* .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

* Add changelog entries for durable workflow support (#4436)

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

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

* Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README

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

* 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>

* Align DurableTask.Client.AzureManaged to 1.19.1

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

* 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>

* 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>

* Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0"

* Add the local.settings.json files to the sample which were previously ignored. This aligns with our other samples.

* Increase timeout for tests as CI has them failing transiently.

* increaset timeout value for azure functions integration tests.

* Add YieldsOutput(string) to workflow shared state sample executors

ValidateOrder and EnrichOrder call YieldOutputAsync with string messages,
but only their TOutput (OrderDetails) was in the allowed yield types.
This caused TargetInvocationException in the WorkflowSharedState sample
validation integration test.

* Downgrade the durable packages to 1.18.0

* Downgrading Worker.Extensions.DurableTask to 1.12.1

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 23:00:50 +00:00
0fdcfd0f4c Python: preserve A2A message context_id (#4686)
* Python: forward A2A context_id

* Avoid duplicating A2A context ids

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 21:41:31 +00:00
Giles OdigweandGitHub 414496dda7 fix: Azure Redis sample missing session for history persistence (#4692) 2026-03-16 21:34:21 +00:00
55011b7258 Python: Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4716)
* Fix _deduplicate_messages catch-all branch dropping valid repeated messages (#4682)

Remove the catch-all dedup branch that used (role, hash(content_str)) as a
dedup key. This incorrectly treated any two messages with the same role and
identical content as duplicates, dropping valid repeated messages (e.g., a
user saying 'yes' to confirm two separate things).

The tool-specific dedup branches (tool results by call_id, assistant tool
calls by call_id tuple) remain unchanged as they correctly identify true
protocol-level duplicates.

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

* Address review: consecutive-duplicate detection for non-tool messages (#4682)

- Replace blanket dedup removal with consecutive-duplicate detection:
  only skip a message if the immediately preceding message has the same
  role and content, preserving protection against upstream replays while
  allowing identical messages at different conversation points.
- Strengthen test assertions to verify message identity and order, not
  just list length.
- Add tests for consecutive duplicate skipping, non-consecutive
  preservation, and messages with contents=None.

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

* Apply pre-commit auto-fixes

* Use message_id for deduplication instead of content hashing

Deduplicate general messages by message_id when available, replacing
the consecutive-duplicate content check. Two messages with the same id
are definitively the same message (upstream replay), while identical
content with distinct ids (e.g. repeated "yes" confirmations) is
preserved. Messages without a message_id are always kept.

* Fix message_id dedup: truthy check, content-hash fallback, log safety

- Use truthy check (`if msg.message_id`) instead of `is not None` so
  empty-string IDs fall through to content-hash dedup rather than
  collapsing unrelated messages.
- Add content-hash fallback for messages without message_id, preventing
  false negatives from integrations that don't set IDs.
- Remove raw message_id from log format string (addresses log-injection
  surface with control characters).
- Add tests for empty-string message_id edge cases.
- Update existing tests to reflect content-hash dedup behavior.

Fixes #4682

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-16 17:47:33 +00:00
CopilotGitHubcrickmancopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
bf0af178bd .NET - Fix flaky workflows test (#4700)
* Initial plan

* Fix flaky test: initialize creationTime 1 second in the past

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>
2026-03-16 17:33:08 +00:00
1b7940c91e Python: keep MCP cleanup on the owner task (#4687)
* Python: keep MCP cleanup on owner task

* Avoid MCP owner task deadlocks

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

* Fix MCP owner-task timeout tests

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 13:54:05 +00:00
Laveesh RohraandGitHub 2f4c4aa614 Python: Remove bad dependency (#4696)
* Remove bad dependency in requirements

* Remove bad dependency in requirements.txt
2026-03-13 23:15:56 +00:00
Eduard van ValkenburgandGitHub 052ba7be07 Python: normalize empty MCP tool output to null (#4683)
* Python: normalize empty MCP tool output to null

* Python: hardcode null for empty MCP output
2026-03-13 20:03:48 +00:00
Chris GillumandGitHub c67d3523ae .NET: [Durable Agents] Filter empty AIContent from durable agent state responses (#4670)
* Filter empty AIContent from durable agent state responses

Prevent opaque AIContent objects (e.g., with only RawRepresentation set)
from being stored in durable entity state, where they serialize to empty
JSON payloads. Base AIContent instances are kept only if they have
Annotations or AdditionalProperties.

Fixes https://github.com/microsoft/agent-framework/issues/4481

* Update CHANGELOG.md and fix linter violation
2026-03-13 18:16:46 +00:00
Shyju KrishnankuttyandGitHub 83ce6a9602 Sanitize user input in log statements for durable agent samples. (#4656) 2026-03-13 17:38:55 +00:00
50fdcbaf57 Python: chore(python): improve dependency range automation (#4343)
* chore(python): improve dependency range automation

- tighten dependency bounds and coding standards guidance\n- add dependency range validation workflow, reporting, and issue automation\n- update related tests and dependency pins for compatibility

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

* updated text and pyarrow

* new lock

* fixed workflow

* updated deps

* fix tiktoken

* chore(python): refine dependency validation workflows

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

* docs(python): add high-level dependency validation comments

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

* WIP

* added additional comments and excludes

* added dev dependency handling and workflow and updates to package ranges

* added readme and simplified commands

* fix markers

* chore(python): address dependency review feedback

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

* Tighten dependency bounds, remove stale overrides, restore Python 3.10 support

- Apply dependency bound policy across all packages: stable >=1.0 deps use
  >=floor,<next_major; pre-1.0/prerelease deps use validated hard-bounded ranges
- Remove stale root tool.uv.override-dependencies (uvicorn, websockets, grpcio)
- Lower github_copilot requires-python to >=3.10 with github-copilot-sdk gated
  behind python_version >= 3.11 marker; import raises ImportError on 3.10
- Skip github_copilot pyright/mypy/test tasks on Python <3.11
- Use version-conditional pyrightconfig for samples on Python 3.10
- Add compatibility fix in core responses client for older openai typed dicts
- Normalize uv.lock prerelease mode and refresh dev dependencies
- Update CODING_STANDARD.md, DEV_SETUP.md, and package management skill docs

Closes #902

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

* small tweaks

* add note in workflow

* fix workflows and several versions

* fix duplicate

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 12:32:37 +00:00
SergeyMenshykhandGitHub 67b0282813 Bump rollup from 7.5.9 to 7.5.11 (#4688) 2026-03-13 12:30:29 +00:00
Roger BarretoandGitHub 0009e330af Fix hosted agent samples Docker build failures due to experimental API warnings (#4641)
Add #pragma warning disable directives to suppress experimental API
diagnostics that cause build errors in Docker isolation (where repo-level
Directory.Build.props is not inherited):

- AgentWithHostedMCP: suppress MEAI001 (HostedMcpServerTool) and OPENAI001
  (GetResponsesClient)
- FoundrySingleAgent: suppress CA2252 (AIProjectClient preview features)
- FoundryMultiAgent: suppress CA2252 (AIProjectClient preview features)

Fixes #4365
2026-03-13 10:13:59 +00:00
a4b9539b62 [BREAKING] Python: clean up kwargs across agents, chat clients, tools, and sessions (#4581)
* Python: clean up kwargs across agents, chat clients, tools, and sessions (#3642)

Audit and refactor public **kwargs usage across core agents, chat clients,
tools, sessions, and provider packages per the migration strategy codified
in CODING_STANDARD.md.

Key changes:
- Add explicit runtime buckets: function_invocation_kwargs and client_kwargs
  on RawAgent.run() and chat client get_response() layers.
- Refactor FunctionTool to prefer explicit ctx: FunctionInvocationContext
  injection; legacy **kwargs tools still work via _forward_runtime_kwargs.
- Refactor Agent.as_tool() to use direct JSON schema, always-streaming
  wrapper, approval_mode parameter, and UserInputRequiredException
  propagation (integrates PR #4568 behavior).
- Remove implicit session bleeding into FunctionInvocationContext; tools
  that need a session must receive it via function_invocation_kwargs.
- Lower chat-client layers after FunctionInvocationLayer accept only
  compatibility **kwargs (client_kwargs flattened, function_invocation_kwargs
  ignored).
- Add layered docstring composition from Raw... implementations via
  _docstrings.py helper.
- Clean up provider constructors to use explicit additional_properties.
- Deprecation warnings on legacy direct kwargs paths.
- Update samples, tests, and typing across all 23 packages.

Resolves #3642

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

* clarified docstring

* feedback fixes

* Add unit tests for _docstrings.py build/apply helpers

Tests cover: no docstring source, no extra kwargs, appending to existing
Keyword Args section, inserting after Args, inserting in plain docstrings,
multiline descriptions, ordering, and apply_layered_docstring.

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

* Add test for propagate_session TypeError on non-AgentSession values

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

* Add tests for multi-content and empty UserInputRequiredException propagation

Cover the branching logic in _try_execute_function_calls for:
- Multiple user_input_request items in a single exception (extra_user_input_contents path)
- Empty contents list (fallback function_result path)

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

* Add tests for DurableAIAgent.get_session forwarding service_session_id

Verifies get_session correctly forwards service_session_id and session_id
to the executor's get_new_session, replacing the removed kwargs test.

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

* Simplify ag-ui test stub to read session from client_kwargs only

Remove dual-mode detection (client_kwargs vs raw kwargs fallback) from
the test mock. Session is now read exclusively from client_kwargs,
matching the settled public calling convention.

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

* updated create and get sessions in durable

* fixed docstrings

* fix test

* updated session handling

* updated from main

* updated tests

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 08:58:32 +00:00
Eduard van ValkenburgandGitHub b7990908fe fix duplicate names between supplied tools and mcp servers (#4649) 2026-03-13 08:22:56 +00:00
84bae0f42a Python: Fix type hint for Case and Default (#3985)
* Fix type hint for `Case` and `Default`

* Add test

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-03-13 08:17:24 +00:00
f696ac9b57 Python: A2AAgent defaults name/description from AgentCard (#4661)
* Python: A2AAgent defaults name/description from AgentCard

When an AgentCard is provided but name/description are not explicitly
set, A2AAgent now falls back to agent_card.name and agent_card.description.
This avoids redundant duplication when constructing A2AAgent instances,
especially in GroupChat orchestrations where name and description are
essential for routing decisions.

Explicit values still take precedence over card values.

Fixes #4630

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

* Use 'is None' checks instead of truthiness for name/description fallback

Ensures explicitly provided empty strings are not overridden by
agent_card values. Adds test for the empty string edge case.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 00:14:23 +00:00
5e33deff45 Python: Unify tool results as Content items with rich content support (#4331)
* feat(python): allow @tool functions to return rich content (images, audio)

Add support for tool functions to return Content objects that the model can perceive natively. Closes #4272

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

* Anthropic logging + mypy fix

* Address PR review: fix MCP ordering, fold helper into from_function_result, fix Chat client

- Preserve original content order in MCP tool results instead of text-first
- Move _build_function_result logic into Content.from_function_result()
- Chat Completions: inject user message for rich items (API only supports string tool content)
- Update tests for ordering and new from_function_result behavior

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

* Use native Responses API multi-part output, warn+omit for Chat client

- Responses client: put rich items directly in function_call_output's
  output field as list (native API support) instead of user message injection
- Chat client: warn and omit rich items (API doesn't support multi-part
  tool results), matching Ollama/Bedrock pattern
- Unify test image: use sample_image.jpg across all integration tests
- Add Azure OpenAI Responses integration test
- Assert model describes house image to verify perception

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

* Fix lint: remove print statement, wrap long line

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

* Address review feedback: bug fixes, single-pass MCP, unit tests

- Add isinstance guard in from_function_result for non-Content lists
- Fix Anthropic empty tool_content fallback to string result
- Fix Content(type='text', text=None) edge case in parse_result
- Rewrite MCP _parse_tool_result_from_mcp as single-pass (no index counters)
- Add Anthropic unit tests: data image, uri image, unsupported media, all-unsupported
- Add OpenAI Chat unit test: rich items warning and omission
- Add OpenAI Responses unit tests: function_result with/without items
- Add test_types tests: only-rich-items list, non-Content list fallback

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

* Fix pyright errors: add type ignore comments for Any list iteration

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

* Fix mypy/pyright: ensure ToolExecutionException receives str

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

* Fix lint: remove duplicate test_prepare_options_excludes_conversation_id

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

* refactor: unify all tool results into Content items

* addressed copilot comments

* pyright fix

* small fix

* comments

* fix: address Copilot review - warnings, blob safety, dedup

- Add warning logs when rich content is dropped in Claude agent and
  MCP server handlers (matching Chat/Bedrock/Ollama pattern)
- Defensive blob URI construction: wrap plain base64 in data: prefix
- Simplify Chat client _prepare_content_for_openai to use content.result
- Simplify Responses client text-only path, remove redundant nesting
- Add test for plain base64 blob without data: prefix

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

* Fix token double-counting in compaction and address review comments

- Exclude items from _serialize_content() to prevent double-counting
  tokens when items mirrors result in function_result content
- Add rich content warning in GitHub Copilot agent tool handler
- Replace raw Content debug log with concise item count/type summary
- Update stale test comments about FunctionTool.invoke return type

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 22:30:09 +00:00
b6a1315386 fix: omit toolConfig when tool_choice="none" in BedrockChatClient (#4535)
Bedrock's Converse API only accepts "auto", "any", or "tool" as valid
toolChoice keys. The previous code mapped tool_choice="none" to
{"none": {}}, which causes a botocore.exceptions.ParamValidationError.

When tool_choice="none" (set by FunctionInvocationLayer after exhausting
max iterations), the fix now omits toolConfig entirely so the model
won't attempt tool calls.

Added tests for tool_choice="none", "auto", and "required" modes.

Fixes #4529

Co-authored-by: Eduard van Valkenburg <eavanvalkenburg@users.noreply.github.com>
2026-03-12 18:49:08 +00:00
ed2fb3b9dd Python: Fix state snapshot to use deepcopy so nested mutations are detected in durable workflow activities (#4518)
* Use deepcopy for state snapshot to detect nested mutations (#4500)

Replace dict() shallow copy with copy.deepcopy() when snapshotting
workflow state before activity execution. The shallow copy shared
references to nested objects (dicts, lists), so in-place mutations by
executors were reflected in both the snapshot and live state, producing
an empty diff and preventing state updates from propagating to
downstream activities.

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

* Python: Fix state snapshot to use deepcopy so nested mutations are detected in durable workflow activities

Fixes #4500

* Address PR review: remove report, extract testable helpers (#4500)

- Delete REPRODUCTION_REPORT.md (debugging artifact with local paths
  and raw LLM output)
- Extract _create_state_snapshot() and _compute_state_updates() as
  module-level helpers in _app.py so tests exercise the production
  code path
- Update TestStateSnapshotDiff to import and use production helpers
  instead of reimplementing snapshot/diff logic locally

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

* Apply pre-commit auto-fixes

* Add regression tests proving shallow copy bug and deep copy isolation (#4500)

Add two additional tests to TestStateSnapshotDiff:
- test_shallow_copy_would_miss_nested_mutations: reproduces the original
  bug by demonstrating that dict() (shallow copy) misses nested mutations
- test_create_state_snapshot_isolates_nested_objects: verifies the
  production _create_state_snapshot helper creates a true deep copy

These tests ensure a regression back to shallow copy would be caught.

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

* Add integration test exercising full activity code path (#4500)

Address PR review comment: add test_executor_activity_detects_nested_state_mutations
that captures the actual executor_activity function from _setup_executor_activity
and verifies it detects in-place nested mutations. This test would fail if
_app.py line 314 regressed from _create_state_snapshot() back to dict().

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

* Address review feedback for #4518: review comment fixes

* Address PR review feedback for state snapshot diff

- Inline _compute_state_updates logic at call site to reuse precomputed
  original_keys/current_keys sets, avoiding redundant set allocations
- Fix test docstring to describe behavioral regression instead of
  hard-coding a specific line number
- Use SOURCE_ORCHESTRATOR constant in integration test instead of
  literal string

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

* Apply pre-commit auto-fixes

* fix: remove unused _compute_state_updates from _app.py (#4518)

The function was inlined per review comment, making the module-level
helper unused and triggering a pyright reportUnusedFunction error.
Move the helper into the test file where it is still needed for unit
testing the diffing logic.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 18:43:12 +00:00
Peter IbekweandGitHub aa2ff672fb .NET: Fix to emit WorkflowStartedEvent during workflow execution (#4514)
* Fix bug to emit WorkflowStartedEvent during workflow execution

* Updated based on PR comments
2026-03-12 15:45:17 +00:00
bcb55b4a98 .NET: Update A2A, MCP, and system package dependencies (#4647)
* .NET: Update A2A, MCP, and system package dependencies

Update dependency versions:
- A2A/A2A.AspNetCore: 0.3.3-preview → 0.3.4-preview
- ModelContextProtocol: 0.8.0-preview.1 → 1.1.0
- Microsoft.Bcl.AsyncInterfaces: 10.0.3 → 10.0.4
- System.Linq.AsyncEnumerable: 10.0.0 → 10.0.4
- Add Microsoft.Bcl.Memory 10.0.4

Remove internal polyfill extensions now provided by A2A SDK 0.3.4:
- A2AMetadataExtensions (source + tests)
- AdditionalPropertiesDictionaryExtensions (source + tests)

Update DefaultMcpToolHandler to match MCP SDK 1.1.0 API changes where
ImageContentBlock.Data and AudioContentBlock.Data changed from string
to ReadOnlyMemory<byte>.

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

* address pr review comments

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 14:16:36 +00:00
westeyandGitHub 921c5f9c17 .NET: Include ReasoningEncryptedContent by default when stored output disabled with Responses (#4623)
* Include ReasoningEncryptedContent by default when stored output disabled

* Fix formatting

* Fix formatter
2026-03-12 09:42:20 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fcdaaff9cd Bump rollup from 4.47.1 to 4.59.0 in /python/packages/devui/frontend (#4338)
Bumps [rollup](https://github.com/rollup/rollup) from 4.47.1 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.47.1...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 02:42:59 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
384291ba27 Bump minimatch from 3.1.2 to 3.1.5 in /python/packages/devui/frontend (#4337)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-12 02:42:02 +00:00
Tushar MudiGitHubREDMOND\tusharmudi <tusharmudi@microsoft.com>
378bee577e Fix CWE-863: Validate function approval responses in DevUI executor (#4598)
The DevUI /v1/responses endpoint accepts function_approval_response content
without verifying that the request_id corresponds to a real pending approval
request issued by the server. This allows forged approval responses to
execute arbitrary tools with attacker-controlled arguments, bypassing
approval_mode='always_require'.

Changes:
- Track outgoing approval requests in a server-side registry
  (_pending_approvals) keyed by request_id
- Validate incoming approval responses against this registry; reject
  any response whose request_id was not issued by the server
- Use server-stored function_call data (tool name, arguments, call_id)
  instead of client-supplied data when constructing the approval response
- Consume request_ids on use (pop from registry) to prevent replay attacks

Tests:
- 8 new tests covering forged rejection, server-data enforcement,
  anti-replay, multiple independent approvals, and edge cases

Co-authored-by: REDMOND\tusharmudi <tusharmudi@microsoft.com>
2026-03-12 02:34:31 +00:00
18e433fc6d Python: Validate approval responses against server-side pending request registry (#4548)
* Validate approval responses against server-side pending request registry

* improvements

* pin GHCP sdk version to non-breaking for now

* Pin CHCP sdk to LKG.

* really fix GHCP sdk pkg version

* Fix HITL approval validation security gaps and memory leak

- Validate rejected approval responses against pending_approvals registry,
  not just approved ones. Fabricated rejections without a prior request are
  now stripped from messages before reaching the LLM.
- Bound _pending_approvals with OrderedDict + LRU eviction (max 10k) to
  prevent unbounded memory growth from abandoned approval requests.
- Skip registration when function_call.name is None/empty; log warning
  when content.id or function_call is missing at registration time.
- Document pending_approvals parameter in run_agent_stream docstring.
- Add test for fabricated rejection attack scenario.
- Assert pending approval entry is preserved after function name mismatch.
- Pre-populate pending_approvals in rejection test for correct validation.

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

* Apply pre-commit auto-fixes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 23:21:29 +00:00
301 changed files with 25153 additions and 3475 deletions
+4 -4
View File
@@ -85,7 +85,7 @@ jobs:
workflow-samples
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
- name: Build dotnet solutions
@@ -165,7 +165,7 @@ jobs:
echo "COSMOSDB_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -281,7 +281,7 @@ jobs:
# Generate test reports and check coverage
- name: Generate test reports
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.1
uses: danielpalme/ReportGenerator-GitHub-Action@5.5.3
with:
reports: "./TestResults/Coverage/**/*.cobertura.xml"
targetdir: "./TestResults/Reports"
@@ -289,7 +289,7 @@ jobs:
- name: Upload coverage report artifact
if: matrix.targetFramework == env.COVERAGE_FRAMEWORK
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: CoverageReport-${{ matrix.os }}-${{ matrix.targetFramework }}-${{ matrix.configuration }} # Artifact name
path: ./TestResults/Reports # Directory containing files to upload
@@ -50,7 +50,7 @@ jobs:
echo "COSMOS_EMULATOR_AVAILABLE=true" >> $env:GITHUB_ENV
- name: Setup dotnet
uses: actions/setup-dotnet@v5.1.0
uses: actions/setup-dotnet@v5.2.0
with:
global-json-file: ${{ github.workspace }}/dotnet/global.json
@@ -0,0 +1,216 @@
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
name: Python - Dependency Range Validation
on:
workflow_dispatch:
permissions:
contents: write
issues: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
dependency-range-validation:
name: Dependency Range Validation
runs-on: ubuntu-latest
env:
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
# then we will have to reevaluate.
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Run dependency range validation
id: validate_ranges
# Keep workflow running so we can still publish diagnostics from this run.
continue-on-error: true
run: uv run poe validate-dependency-bounds-project --mode upper --project "*"
working-directory: ./python
- name: Upload dependency range report
# Always publish the report so failures are inspectable even when validation fails.
if: always()
uses: actions/upload-artifact@v7
with:
name: dependency-range-results
path: python/scripts/dependencies/dependency-range-results.json
if-no-files-found: warn
- name: Create issues for failed dependency candidates
# Always process the report so failed candidates create actionable tracking issues.
if: always()
uses: actions/github-script@v8
with:
script: |
const fs = require("fs")
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
if (!fs.existsSync(reportPath)) {
core.warning(`No dependency range report found at ${reportPath}`)
return
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
const dependencyFailures = []
for (const packageResult of report.packages ?? []) {
for (const dependency of packageResult.dependencies ?? []) {
const candidateVersions = new Set(dependency.candidate_versions ?? [])
const failedAttempts = (dependency.attempts ?? []).filter(
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
)
if (!failedAttempts.length) {
continue
}
const failuresByVersion = new Map()
for (const attempt of failedAttempts) {
const version = attempt.trial_upper || "unknown"
if (!failuresByVersion.has(version)) {
failuresByVersion.set(version, attempt.error || "No error output captured.")
}
}
dependencyFailures.push({
packageName: packageResult.package_name,
projectPath: packageResult.project_path,
dependencyName: dependency.name,
originalRequirements: dependency.original_requirements ?? [],
finalRequirements: dependency.final_requirements ?? [],
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
})
}
}
if (!dependencyFailures.length) {
core.info("No failing dependency candidates found.")
return
}
const owner = context.repo.owner
const repo = context.repo.repo
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: "open",
per_page: 100,
})
const openIssueTitles = new Set(
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
)
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
for (const failure of dependencyFailures) {
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
if (openIssueTitles.has(title)) {
core.info(`Issue already exists: ${title}`)
continue
}
const visibleFailures = failure.failedVersions.slice(0, 5)
const omittedCount = failure.failedVersions.length - visibleFailures.length
const failureDetails = visibleFailures
.map(
(entry) =>
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
)
.join("\n\n")
const body = [
"Automated dependency range validation found candidate versions that failed checks.",
"",
`- Package: \`${failure.packageName}\``,
`- Project path: \`${failure.projectPath}\``,
`- Dependency: \`${failure.dependencyName}\``,
`- Original requirements: ${
failure.originalRequirements.length
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
`- Final requirements after run: ${
failure.finalRequirements.length
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
: "_none_"
}`,
"",
"### Failed versions and errors",
failureDetails,
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
"",
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
].join("\n")
await github.rest.issues.create({
owner,
repo,
title,
body,
})
openIssueTitles.add(title)
core.info(`Created issue: ${title}`)
}
- name: Refresh lockfile
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
if: steps.validate_ranges.outcome == 'success'
run: uv lock --upgrade
working-directory: ./python
- name: Commit and push dependency updates
id: commit_updates
if: steps.validate_ranges.outcome == 'success'
run: |
BRANCH="automation/python-dependency-range-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dependency updates to commit."
exit 0
fi
git commit -m "chore: update dependency ranges"
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
# Only open/update PRs for validated updates to keep automation branches trustworthy.
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dependency-range-updates"
PR_TITLE="Python: chore: update dependency ranges"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
This PR was generated by the dependency range validation workflow.
- Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"`
- Updated package dependency bounds
- Refreshed `python/uv.lock` with `uv lock --upgrade`
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
@@ -0,0 +1,91 @@
name: Python - Dev Dependency Upgrade
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
env:
UV_CACHE_DIR: /tmp/.uv-cache
jobs:
upgrade-dev-dependencies:
name: Upgrade Dev Dependencies
runs-on: ubuntu-latest
env:
UV_PYTHON: "3.13"
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
env:
UV_CACHE_DIR: /tmp/.uv-cache
- name: Upgrade dev dependencies and validate workspace
run: uv run poe upgrade-dev-dependencies
working-directory: ./python
- name: Commit and push dev dependency updates
id: commit_updates
run: |
BRANCH="automation/python-dev-dependency-updates"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "${BRANCH}"
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
if git diff --cached --quiet; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No dev dependency updates to commit."
exit 0
fi
git commit -F- <<'EOF'
Python: chore: upgrade dev dependencies
EOF
git push --force-with-lease --set-upstream origin "${BRANCH}"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
- name: Create or update pull request with GitHub CLI
if: steps.commit_updates.outputs.has_changes == 'true'
run: |
BRANCH="automation/python-dev-dependency-updates"
PR_TITLE="Python: chore: upgrade dev dependencies"
PR_BODY_FILE="$(mktemp)"
cat > "${PR_BODY_FILE}" <<'EOF'
### Motivation and Context
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
### Description
- Ran `uv run poe upgrade-dev-dependencies`
- Refreshed dev dependency pins in workspace `pyproject.toml` files
- Refreshed `python/uv.lock` with `uv lock --upgrade`
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
### Contribution Checklist
- [x] The code builds clean without any errors or warnings
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
- [x] All unit tests pass, and I have added new tests where possible
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
EOF
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
if [ -n "${PR_NUMBER}" ]; then
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
else
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
fi
+3
View File
@@ -76,6 +76,9 @@ jobs:
- name: Run lab tests
run: cd packages/lab && uv run poe test
- name: Run resource-intensive lab tests
run: cd packages/lab && uv run pytest -m "resource_intensive and not integration" --junitxml=test-results-resource-intensive.xml
- name: Run lab lint
run: cd packages/lab && uv run poe lint
@@ -46,7 +46,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 01-get-started --save-report --report-name 01-get-started
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-01-get-started
@@ -89,7 +89,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 02-agents --save-report --report-name 02-agents
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-02-agents
@@ -126,7 +126,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 03-workflows --save-report --report-name 03-workflows
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-03-workflows
@@ -165,7 +165,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 04-hosting --save-report --report-name 04-hosting
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-04-hosting
@@ -209,7 +209,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir 05-end-to-end --save-report --report-name 05-end-to-end
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-05-end-to-end
@@ -249,7 +249,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir autogen-migration --save-report --report-name autogen-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-autogen-migration
@@ -295,7 +295,7 @@ jobs:
cd scripts && uv run python -m sample_validation --subdir semantic-kernel-migration --save-report --report-name semantic-kernel-migration
- name: Upload validation report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: validation-report-semantic-kernel-migration
@@ -46,7 +46,7 @@ jobs:
echo "PR_NUMBER=$PR_NUMBER" >> "$GITHUB_ENV"
- name: Pytest coverage comment
id: coverageComment
uses: MishaKav/pytest-coverage-comment@v1.2.0
uses: MishaKav/pytest-coverage-comment@v1.6.0
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
issue-number: ${{ env.PR_NUMBER }}
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
- name: Check coverage threshold
run: python ${{ github.workspace }}/.github/workflows/python-check-coverage.py python-coverage.xml ${{ env.COVERAGE_THRESHOLD }}
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
path: |
python/python-coverage.xml
+3
View File
@@ -205,6 +205,9 @@ WARP.md
**/memory-bank/
**/projectBrief.md
**/tmpclaude*
# Dependency-bound validation reports
python/scripts/dependency-*-results.json
python/scripts/dependencies/dependency-*-results.json
# Azurite storage emulator files
*/__azurite_db_blob__.json*
+5 -5
View File
@@ -4,8 +4,8 @@ status: accepted
contact: westey-m
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
consulted:
informed:
consulted:
informed:
---
# Agent Run Responses Design
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/user-guide/concepts/streaming/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/api/python/strands.agent.agent/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
@@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|-|-|
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/user-guide/concepts/agents/structured-output/) |
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/api/python/strands.agent.agent/) |
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time |
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|-|-|
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
| AWS (Strands) | Exposes a `stop_reason` property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/#agentresult) class with options that are tied closely to LLM operations. |
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/docs/api/python/strands.types.event_loop/) property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) class with options that are tied closely to LLM operations. |
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
+7 -6
View File
@@ -33,14 +33,15 @@
<!-- Newtonsoft.Json -->
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<!-- System.* -->
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.4" />
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.3" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
@@ -101,10 +102,10 @@
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
<!-- A2A -->
<PackageVersion Include="A2A" Version="0.3.3-preview" />
<PackageVersion Include="A2A.AspNetCore" Version="0.3.3-preview" />
<PackageVersion Include="A2A" Version="0.3.4-preview" />
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="0.8.0-preview.1" />
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Inference SDKs -->
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
@@ -126,7 +127,7 @@
<!-- 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" Version="1.12.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" Version="1.0.1" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.3.0" />
<PackageVersion Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" Version="2.1.0" />
+20 -1
View File
@@ -61,6 +61,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>
@@ -520,4 +539,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>
@@ -17,7 +17,7 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
{
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic);
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", SanitizeLogValue(topic));
const int MaxReviewAttempts = 3;
const float ApprovalTimeoutHours = 72;
@@ -34,7 +34,7 @@ internal sealed class Tools(ILogger<Tools> logger)
this._logger.LogInformation(
"Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}",
topic,
SanitizeLogValue(topic),
instanceId);
return $"Workflow started with instance ID: {instanceId}";
@@ -45,7 +45,7 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("The instance ID of the workflow to check")] string instanceId,
[Description("Whether to include detailed information")] bool includeDetails = true)
{
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId);
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
// Get the current agent context using the session-static property
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
@@ -54,7 +54,7 @@ internal sealed class Tools(ILogger<Tools> logger)
if (status is null)
{
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId);
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(instanceId));
return new
{
instanceId,
@@ -78,7 +78,16 @@ internal sealed class Tools(ILogger<Tools> logger)
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
[Description("Feedback to submit")] HumanApprovalResponse feedback)
{
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId);
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback);
}
/// <summary>
/// Sanitizes a user-provided value for safe inclusion in log entries
/// by removing control characters that could be used for log forging.
/// </summary>
private static string SanitizeLogValue(string value) =>
value
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
}
@@ -157,8 +157,8 @@ public sealed class FunctionTriggers
this._logger.LogInformation(
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
conversationId,
cursor ?? "(beginning)");
SanitizeLogValue(conversationId),
SanitizeLogValue(cursor) ?? "(beginning)");
// Check Accept header to determine response format
// text/plain = raw text output (ideal for terminals)
@@ -205,7 +205,7 @@ public sealed class FunctionTriggers
{
if (chunk.Error != null)
{
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error);
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
break;
}
@@ -224,7 +224,7 @@ public sealed class FunctionTriggers
}
catch (OperationCanceledException)
{
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId));
}
return new EmptyResult();
@@ -316,4 +316,20 @@ public sealed class FunctionTriggers
await response.WriteAsync(sb.ToString());
}
/// <summary>
/// Sanitizes a user-provided value for safe inclusion in log entries
/// by removing control characters that could be used for log forging.
/// </summary>
private static string? SanitizeLogValue(string? value)
{
if (value is null)
{
return null;
}
return value
.Replace("\r", string.Empty, StringComparison.Ordinal)
.Replace("\n", string.Empty, StringComparison.Ordinal);
}
}
@@ -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,10 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -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,10 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -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,10 @@
{
"IsEncrypted": false,
"Values": {
"FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None",
"AZURE_OPENAI_ENDPOINT": "<AZURE_OPENAI_ENDPOINT>",
"AZURE_OPENAI_DEPLOYMENT_NAME": "<AZURE_OPENAI_DEPLOYMENT_NAME>"
}
}
@@ -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,184 @@
// 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>
[YieldsOutput(typeof(string))]
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>
[YieldsOutput(typeof(string))]
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 |
@@ -4,6 +4,9 @@
// In this case the OpenAI responses service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework.
// The sample demonstrates how to use MCP tools with auto approval by setting ApprovalMode to NeverRequire.
#pragma warning disable MEAI001 // HostedMcpServerTool, HostedMcpServerToolApprovalMode are experimental
#pragma warning disable OPENAI001 // GetResponsesClient is experimental
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.OpenAI;
using Azure.Identity;
@@ -3,6 +3,8 @@
// This sample demonstrates a multi-agent workflow with Writer and Reviewer agents
// using Azure AI Foundry AIProjectClient and the Agent Framework WorkflowBuilder.
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
using Azure.AI.AgentServer.AgentFramework.Extensions;
using Azure.AI.Projects;
using Azure.Identity;
@@ -4,6 +4,8 @@
// Uses Microsoft Agent Framework with Azure AI Foundry.
// Ready for deployment to Foundry Hosted Agent service.
#pragma warning disable CA2252 // AIProjectClient and Agents API require opting into preview features
using System.ComponentModel;
using System.Globalization;
using System.Text;
@@ -1,36 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Extensions.AI;
namespace A2A;
/// <summary>
/// Extension methods for A2A metadata dictionary.
/// </summary>
internal static class A2AMetadataExtensions
{
/// <summary>
/// Converts a dictionary of metadata to an <see cref="AdditionalPropertiesDictionary"/>.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is public.
/// </remarks>
/// <param name="metadata">The metadata dictionary to convert.</param>
/// <returns>The converted <see cref="AdditionalPropertiesDictionary"/>, or null if the input is null or empty.</returns>
internal static AdditionalPropertiesDictionary? ToAdditionalProperties(this Dictionary<string, JsonElement>? metadata)
{
if (metadata is not { Count: > 0 })
{
return null;
}
var additionalProperties = new AdditionalPropertiesDictionary();
foreach (var kvp in metadata)
{
additionalProperties[kvp.Key] = kvp.Value;
}
return additionalProperties;
}
}
@@ -1,44 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Microsoft.Extensions.AI;
/// <summary>
/// Extension methods for AdditionalPropertiesDictionary.
/// </summary>
internal static class AdditionalPropertiesDictionaryExtensions
{
/// <summary>
/// Converts an <see cref="AdditionalPropertiesDictionary"/> to a dictionary of <see cref="JsonElement"/> values suitable for A2A metadata.
/// </summary>
/// <remarks>
/// This method can be replaced by the one from A2A SDK once it is available.
/// </remarks>
/// <param name="additionalProperties">The additional properties dictionary to convert, or <c>null</c>.</param>
/// <returns>A dictionary of JSON elements representing the metadata, or <c>null</c> if the input is null or empty.</returns>
internal static Dictionary<string, JsonElement>? ToA2AMetadata(this AdditionalPropertiesDictionary? additionalProperties)
{
if (additionalProperties is not { Count: > 0 })
{
return null;
}
var metadata = new Dictionary<string, JsonElement>();
foreach (var kvp in additionalProperties)
{
if (kvp.Value is JsonElement)
{
metadata[kvp.Key] = (JsonElement)kvp.Value!;
continue;
}
metadata[kvp.Key] = JsonSerializer.SerializeToElement(kvp.Value, A2AJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object)));
}
return metadata;
}
}
@@ -104,15 +104,17 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
{
State state = this._sessionState.GetOrInitializeState(context.Session);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
// Reduce existing messages before adding new messages from the current turn.
// This ensures messages from the current turn (including function calls and tool results)
// are always preserved in full and are not immediately reduced.
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
// Add request and response messages to the provider
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
state.Messages.AddRange(allNewMessages);
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
{
// Apply pre-write reduction strategy if configured
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
}
}
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
@@ -2,19 +2,47 @@
## [Unreleased]
- Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436))
## v1.0.0-preview.260219.1
- [BREAKING] Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
## v1.0.0-preview.260212.1
- [BREAKING] Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
## v1.0.0-preview.260209.1
- [BREAKING] Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
## v1.0.0-preview.260205.1
- [BREAKING] Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
- [BREAKING] Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
## v1.0.0-preview.260127.1
- [BREAKING] Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
## v1.0.0-preview.260108.1
- [BREAKING] Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067))
## v1.0.0-preview.251219.1
- Filter empty `AIContent` from durable agent state responses ([#4670](https://github.com/microsoft/agent-framework/pull/4670))
## v1.0.0-preview.260311.1
### Changed
- Added TTL configuration for durable agent entities ([#2679](https://github.com/microsoft/agent-framework/pull/2679))
- Switch to new "Run" method name ([#2843](https://github.com/microsoft/agent-framework/pull/2843))
- Removed AgentThreadMetadata and used AgentSessionId directly instead ([#3067](https://github.com/microsoft/agent-framework/pull/3067));
- Renamed AgentThread to AgentSession ([#3430](https://github.com/microsoft/agent-framework/pull/3430))
- Moved AgentSession.Serialize to AIAgent.SerializeSession ([#3650](https://github.com/microsoft/agent-framework/pull/3650))
- Renamed serializedSession parameter to serializedState on DeserializeSessionAsync for consistency ([#3681](https://github.com/microsoft/agent-framework/pull/3681))
- Introduce Core method pattern for Session management methods on AIAgent ([#3699](https://github.com/microsoft/agent-framework/pull/3699))
- Changed AIAgent.SerializeSession to AIAgent.SerializeSessionAsync ([#3879](https://github.com/microsoft/agent-framework/pull/3879))
- Changed ChatHistory and AIContext Providers to have pipeline semantics ([#3806](https://github.com/microsoft/agent-framework/pull/3806))
- Marked all `RunAsync<T>` overloads as `new`, added missing ones, and added support for primitives and arrays ([#3803](https://github.com/microsoft/agent-framework/pull/3803))
- Improve session cast error message quality and consistency ([#3973](https://github.com/microsoft/agent-framework/pull/3973))
NOTE: Some of the above changes may have been part of earlier releases not mentioned in this file.
## v1.0.0-preview.251204.1
@@ -141,4 +141,15 @@ public sealed class DurableAgentsOptions
{
return this._agentTimeToLive.TryGetValue(agentName, out TimeSpan? ttl) ? ttl : this.DefaultTimeToLive;
}
/// <summary>
/// Determines whether an agent with the specified name is registered.
/// </summary>
/// <param name="agentName">The name of the agent to locate. Cannot be null.</param>
/// <returns>true if an agent with the specified name is registered; otherwise, false.</returns>
internal bool ContainsAgent(string agentName)
{
ArgumentNullException.ThrowIfNull(agentName);
return this._agentFactories.ContainsKey(agentName);
}
}
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.DurableTask;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Custom data converter for durable agents and workflows that ensures proper JSON serialization.
/// </summary>
/// <remarks>
/// This converter handles special cases like <see cref="DurableAgentState"/> using source-generated
/// JSON contexts for AOT compatibility, and falls back to reflection-based serialization for other types.
/// </remarks>
internal sealed class DurableDataConverter : DataConverter
{
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
return typeInfo is not null
? JsonSerializer.Deserialize(data, typeInfo)
: JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Fallback uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
return typeInfo is not null
? JsonSerializer.Serialize(value, typeInfo)
: JsonSerializer.Serialize(value, s_options);
}
}
@@ -0,0 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.DurableTask.Workflows;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Provides configuration options for durable agents and workflows.
/// </summary>
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
public class DurableOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
/// </summary>
internal DurableOptions()
{
this.Workflows = new DurableWorkflowOptions(this);
}
/// <summary>
/// Gets the configuration options for durable agents.
/// </summary>
public DurableAgentsOptions Agents { get; } = new();
/// <summary>
/// Gets the configuration options for durable workflows.
/// </summary>
public DurableWorkflowOptions Workflows { get; }
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Marker class used to track whether core durable task services have been registered.
/// </summary>
/// <remarks>
/// <para>
/// <b>Problem it solves:</b> Users may call configuration methods multiple times:
/// <code>
/// services.ConfigureDurableOptions(...); // 1st call - registers agent A
/// services.ConfigureDurableOptions(...); // 2nd call - registers workflow X
/// services.ConfigureDurableOptions(...); // 3rd call - registers agent B and workflow Y
/// </code>
/// Each call invokes <c>EnsureDurableServicesRegistered</c>. Without this marker, core services like
/// <c>AddDurableTaskWorker</c> and <c>AddDurableTaskClient</c> would be registered multiple times,
/// causing runtime errors or unexpected behavior.
/// </para>
/// <para>
/// <b>How it works:</b>
/// <list type="number">
/// <item><description>First call: No marker in services → register marker + all core services</description></item>
/// <item><description>Subsequent calls: Marker exists → early return, skip core service registration</description></item>
/// </list>
/// </para>
/// <para>
/// <b>Why not use TryAddSingleton for everything?</b>
/// While <c>TryAddSingleton</c> prevents duplicate simple service registrations, it doesn't work for
/// complex registrations like <c>AddDurableTaskWorker</c> which have side effects and configure
/// internal builders. The marker pattern provides a clean, explicit guard for the entire registration block.
/// </para>
/// </remarks>
internal sealed class DurableServicesMarker;
@@ -100,4 +100,131 @@ internal static partial class Logs
public static partial void LogTTLExpirationTimeCleared(
this ILogger logger,
AgentSessionId sessionId);
// Durable workflow logs (EventIds 100-199)
[LoggerMessage(
EventId = 100,
Level = LogLevel.Information,
Message = "Starting workflow '{WorkflowName}' with instance '{InstanceId}'")]
public static partial void LogWorkflowStarting(
this ILogger logger,
string workflowName,
string instanceId);
[LoggerMessage(
EventId = 101,
Level = LogLevel.Information,
Message = "Superstep {Step}: {Count} active executor(s)")]
public static partial void LogSuperstepStarting(
this ILogger logger,
int step,
int count);
[LoggerMessage(
EventId = 102,
Level = LogLevel.Debug,
Message = "Superstep {Step} executors: [{Executors}]")]
public static partial void LogSuperstepExecutors(
this ILogger logger,
int step,
string executors);
[LoggerMessage(
EventId = 103,
Level = LogLevel.Information,
Message = "Workflow completed")]
public static partial void LogWorkflowCompleted(
this ILogger logger);
[LoggerMessage(
EventId = 104,
Level = LogLevel.Warning,
Message = "Workflow '{InstanceId}' terminated early: reached maximum superstep limit ({MaxSupersteps}) with {RemainingExecutors} executor(s) still queued")]
public static partial void LogWorkflowMaxSuperstepsExceeded(
this ILogger logger,
string instanceId,
int maxSupersteps,
int remainingExecutors);
[LoggerMessage(
EventId = 105,
Level = LogLevel.Debug,
Message = "Fan-In executor {ExecutorId}: aggregated {Count} messages from [{Sources}]")]
public static partial void LogFanInAggregated(
this ILogger logger,
string executorId,
int count,
string sources);
[LoggerMessage(
EventId = 106,
Level = LogLevel.Debug,
Message = "Executor '{ExecutorId}' returned result (length: {Length}, messages: {MessageCount})")]
public static partial void LogExecutorResultReceived(
this ILogger logger,
string executorId,
int length,
int messageCount);
[LoggerMessage(
EventId = 107,
Level = LogLevel.Debug,
Message = "Dispatching executor '{ExecutorId}' (agentic: {IsAgentic})")]
public static partial void LogDispatchingExecutor(
this ILogger logger,
string executorId,
bool isAgentic);
[LoggerMessage(
EventId = 108,
Level = LogLevel.Warning,
Message = "Agent '{AgentName}' not found")]
public static partial void LogAgentNotFound(
this ILogger logger,
string agentName);
[LoggerMessage(
EventId = 109,
Level = LogLevel.Debug,
Message = "Edge {Source} -> {Sink}: condition returned false, skipping")]
public static partial void LogEdgeConditionFalse(
this ILogger logger,
string source,
string sink);
[LoggerMessage(
EventId = 110,
Level = LogLevel.Warning,
Message = "Failed to evaluate condition for edge {Source} -> {Sink}, skipping")]
public static partial void LogEdgeConditionEvaluationFailed(
this ILogger logger,
Exception ex,
string source,
string sink);
[LoggerMessage(
EventId = 111,
Level = LogLevel.Debug,
Message = "Edge {Source} -> {Sink}: routing message")]
public static partial void LogEdgeRoutingMessage(
this ILogger logger,
string source,
string sink);
[LoggerMessage(
EventId = 112,
Level = LogLevel.Information,
Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")]
public static partial void LogWaitingForExternalEvent(
this ILogger logger,
string requestPortId);
[LoggerMessage(
EventId = 113,
Level = LogLevel.Information,
Message = "Received external event for RequestPort '{RequestPortId}'")]
public static partial void LogReceivedExternalEvent(
this ILogger logger,
string requestPortId);
}
@@ -17,7 +17,6 @@
</PropertyGroup>
<PropertyGroup>
<InjectSharedThrow>true</InjectSharedThrow>
<InjectSharedStructuredOutput>true</InjectSharedStructuredOutput>
</PropertyGroup>
@@ -28,6 +27,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Microsoft.Agents.AI.Workflows\Microsoft.Agents.AI.Workflows.csproj" />
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
</ItemGroup>
@@ -1,18 +1,18 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Agents.AI.DurableTask.State;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
using Microsoft.DurableTask.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask;
/// <summary>
/// Agent-specific extension methods for the <see cref="IServiceCollection"/> class.
/// Extension methods for configuring durable agents and workflows with dependency injection.
/// </summary>
public static class ServiceCollectionExtensions
{
@@ -30,77 +30,331 @@ public static class ServiceCollectionExtensions
}
/// <summary>
/// Configures the Durable Agents services via the service collection.
/// Configures durable agents, automatically registering agent entities.
/// </summary>
/// <remarks>
/// <para>
/// This method provides an agent-focused configuration experience.
/// If you need to configure both agents and workflows, consider using
/// <see cref="ConfigureDurableOptions"/> instead.
/// </para>
/// <para>
/// Multiple calls to this method are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="configure">A delegate to configure the durable agents.</param>
/// <param name="workerBuilder">A delegate to configure the Durable Task worker.</param>
/// <param name="clientBuilder">A delegate to configure the Durable Task client.</param>
/// <returns>The service collection.</returns>
/// <param name="workerBuilder">Optional delegate to configure the Durable Task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the Durable Task client.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
return services.ConfigureDurableOptions(
options => configure(options.Agents),
workerBuilder,
clientBuilder);
}
/// <summary>
/// Configures durable workflows, automatically registering orchestrations and activities.
/// </summary>
/// <remarks>
/// <para>
/// This method provides a workflow-focused configuration experience.
/// If you need to configure both agents and workflows, consider using
/// <see cref="ConfigureDurableOptions"/> instead.
/// </para>
/// <para>
/// Multiple calls to this method are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection to configure.</param>
/// <param name="configure">A delegate to configure the workflow options.</param>
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection ConfigureDurableWorkflows(
this IServiceCollection services,
Action<DurableWorkflowOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
return services.ConfigureDurableOptions(
options => configure(options.Workflows),
workerBuilder,
clientBuilder);
}
/// <summary>
/// Configures durable agents and workflows, automatically registering orchestrations, activities, and agent entities.
/// </summary>
/// <remarks>
/// <para>
/// This is the recommended entry point for configuring durable functionality. It provides unified configuration
/// for both agents and workflows through a single <see cref="DurableOptions"/> instance, ensuring agents
/// referenced in workflows are automatically registered.
/// </para>
/// <para>
/// Multiple calls to this method (or to <see cref="ConfigureDurableAgents"/>
/// and <see cref="ConfigureDurableWorkflows"/>) are supported and configurations are composed additively.
/// </para>
/// </remarks>
/// <param name="services">The service collection to configure.</param>
/// <param name="configure">A delegate to configure the durable options for both agents and workflows.</param>
/// <param name="workerBuilder">Optional delegate to configure the durable task worker.</param>
/// <param name="clientBuilder">Optional delegate to configure the durable task client.</param>
/// <returns>The service collection for chaining.</returns>
/// <example>
/// <code>
/// services.ConfigureDurableOptions(options =>
/// {
/// // Register agents not part of workflows
/// options.Agents.AddAIAgent(standaloneAgent);
///
/// // Register workflows - agents in workflows are auto-registered
/// options.Workflows.AddWorkflow(myWorkflow);
/// },
/// workerBuilder: builder => builder.UseDurableTaskScheduler(connectionString),
/// clientBuilder: builder => builder.UseDurableTaskScheduler(connectionString));
/// </code>
/// </example>
public static IServiceCollection ConfigureDurableOptions(
this IServiceCollection services,
Action<DurableOptions> configure,
Action<IDurableTaskWorkerBuilder>? workerBuilder = null,
Action<IDurableTaskClientBuilder>? clientBuilder = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
DurableAgentsOptions options = services.ConfigureDurableAgents(configure);
// Get or create the shared DurableOptions instance for configuration
DurableOptions sharedOptions = GetOrCreateSharedOptions(services);
// A worker is required to run the agent entities
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
// Apply the configuration immediately to capture agent names for keyed service registration
configure(sharedOptions);
builder.AddTasks(registry =>
{
foreach (string name in options.GetAgentFactories().Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(name));
}
});
});
// Register keyed services for any new agents
RegisterAgentKeyedServices(services, sharedOptions);
// The client is needed to send notifications to the agent entities from non-orchestrator code
if (clientBuilder != null)
{
services.AddDurableTaskClient(clientBuilder);
}
services.AddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
// Register core services only once
EnsureDurableServicesRegistered(services, sharedOptions, workerBuilder, clientBuilder);
return services;
}
// This is internal because it's also used by Microsoft.Azure.Functions.DurableAgents, which is a friend assembly project.
internal static DurableAgentsOptions ConfigureDurableAgents(
this IServiceCollection services,
Action<DurableAgentsOptions> configure)
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
{
DurableAgentsOptions options = new();
configure(options);
// Look for an existing DurableOptions registration
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agents = options.GetAgentFactories();
// The agent dictionary contains the real agent factories, which is used by the agent entities.
services.AddSingleton(agents);
// Register the options so AgentEntity can access TTL configuration
services.AddSingleton(options);
// The keyed services are used to resolve durable agent *proxy* instances for external clients.
foreach (var factory in agents)
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
return existing;
}
// A custom data converter is needed because the default chat client uses camel case for JSON properties,
// which is not the default behavior for the Durable Task SDK.
services.AddSingleton<DataConverter, DefaultDataConverter>();
// Create a new shared options instance
DurableOptions options = new();
services.AddSingleton(options);
return options;
}
private static void RegisterAgentKeyedServices(IServiceCollection services, DurableOptions options)
{
foreach (KeyValuePair<string, Func<IServiceProvider, AIAgent>> factory in options.Agents.GetAgentFactories())
{
// Only add if not already registered (to support multiple Configure* calls)
if (!services.Any(d => d.ServiceType == typeof(AIAgent) && d.IsKeyedService && Equals(d.ServiceKey, factory.Key)))
{
services.AddKeyedSingleton(factory.Key, (sp, _) => factory.Value(sp).AsDurableAgentProxy(sp));
}
}
}
/// <summary>
/// Ensures that the core durable services are registered only once, regardless of how many
/// times the configuration methods are called.
/// </summary>
private static void EnsureDurableServicesRegistered(
IServiceCollection services,
DurableOptions sharedOptions,
Action<IDurableTaskWorkerBuilder>? workerBuilder,
Action<IDurableTaskClientBuilder>? clientBuilder)
{
// Use a marker to ensure we only register core services once
if (services.Any(d => d.ServiceType == typeof(DurableServicesMarker)))
{
return;
}
services.AddSingleton<DurableServicesMarker>();
services.TryAddSingleton<DurableWorkflowRunner>();
// Configure Durable Task Worker - capture sharedOptions reference in closure.
// The options object is populated by all Configure* calls before the worker starts.
if (workerBuilder is not null)
{
services.AddDurableTaskWorker(builder =>
{
workerBuilder?.Invoke(builder);
builder.AddTasks(registry => RegisterTasksFromOptions(registry, sharedOptions));
});
}
// Configure Durable Task Client
if (clientBuilder is not null)
{
services.AddDurableTaskClient(clientBuilder);
services.TryAddSingleton<IWorkflowClient, DurableWorkflowClient>();
services.TryAddSingleton<IDurableAgentClient, DefaultDurableAgentClient>();
}
// Register workflow and agent services
services.TryAddSingleton<DataConverter, DurableDataConverter>();
// Register agent factories resolver - returns factories from the shared options
services.TryAddSingleton(
sp => sp.GetRequiredService<DurableOptions>().Agents.GetAgentFactories());
// Register DurableAgentsOptions resolver
services.TryAddSingleton(sp => sp.GetRequiredService<DurableOptions>().Agents);
}
private static void RegisterTasksFromOptions(DurableTaskRegistry registry, DurableOptions durableOptions)
{
// Build registrations for all workflows including sub-workflows
List<WorkflowRegistrationInfo> registrations = [];
HashSet<string> registeredActivities = [];
HashSet<string> registeredOrchestrations = [];
DurableWorkflowOptions workflowOptions = durableOptions.Workflows;
foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList())
{
BuildWorkflowRegistrationRecursive(
workflow,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
}
IReadOnlyDictionary<string, Func<IServiceProvider, AIAgent>> agentFactories =
durableOptions.Agents.GetAgentFactories();
// Register orchestrations and activities
foreach (WorkflowRegistrationInfo registration in registrations)
{
// Register with DurableWorkflowInput<object> - the DataConverter handles serialization/deserialization
registry.AddOrchestratorFunc<DurableWorkflowInput<object>, DurableWorkflowResult>(
registration.OrchestrationName,
(context, input) => RunWorkflowOrchestrationAsync(context, input, durableOptions));
foreach (ActivityRegistrationInfo activity in registration.Activities)
{
ExecutorBinding binding = activity.Binding;
registry.AddActivityFunc<string, string>(
activity.ActivityName,
(context, input) => DurableActivityExecutor.ExecuteAsync(binding, input));
}
}
// Register agent entities
foreach (string agentName in agentFactories.Keys)
{
registry.AddEntity<AgentEntity>(AgentSessionId.ToEntityName(agentName));
}
}
private static void BuildWorkflowRegistrationRecursive(
Workflow workflow,
DurableWorkflowOptions workflowOptions,
List<WorkflowRegistrationInfo> registrations,
HashSet<string> registeredActivities,
HashSet<string> registeredOrchestrations)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
if (!registeredOrchestrations.Add(orchestrationName))
{
return;
}
registrations.Add(BuildWorkflowRegistration(workflow, registeredActivities));
// Process subworkflows recursively to register them as separate orchestrations
foreach (SubworkflowBinding subworkflowBinding in workflow.ReflectExecutors()
.Select(e => e.Value)
.OfType<SubworkflowBinding>())
{
Workflow subWorkflow = subworkflowBinding.WorkflowInstance;
workflowOptions.AddWorkflow(subWorkflow);
BuildWorkflowRegistrationRecursive(
subWorkflow,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
}
}
private static WorkflowRegistrationInfo BuildWorkflowRegistration(
Workflow workflow,
HashSet<string> registeredActivities)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name!);
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
List<ActivityRegistrationInfo> activities = [];
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
.Where(e => IsActivityBinding(e.Value)))
{
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
if (registeredActivities.Add(activityName))
{
activities.Add(new ActivityRegistrationInfo(activityName, entry.Value));
}
}
return new WorkflowRegistrationInfo(orchestrationName, activities);
}
/// <summary>
/// Returns <see langword="true"/> for bindings that should be registered as Durable Task activities.
/// <see cref="AIAgentBinding"/> (Durable Entities), <see cref="SubworkflowBinding"/> (sub-orchestrations),
/// and <see cref="RequestPortBinding"/> (human-in-the-loop via external events) use specialized dispatch
/// and are excluded.
/// </summary>
private static bool IsActivityBinding(ExecutorBinding binding)
=> binding is not AIAgentBinding
and not SubworkflowBinding
and not RequestPortBinding;
private static async Task<DurableWorkflowResult> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DurableWorkflowInput<object> workflowInput,
DurableOptions durableOptions)
{
ILogger logger = context.CreateReplaySafeLogger("DurableWorkflow");
DurableWorkflowRunner runner = new(durableOptions);
// ConfigureAwait(true) is required in orchestration code for deterministic replay.
return await runner.RunWorkflowOrchestrationAsync(context, workflowInput, logger).ConfigureAwait(true);
}
private sealed record WorkflowRegistrationInfo(string OrchestrationName, List<ActivityRegistrationInfo> Activities);
private sealed record ActivityRegistrationInfo(string ActivityName, ExecutorBinding Binding);
/// <summary>
/// Validates that an agent with the specified name has been registered.
/// </summary>
@@ -124,63 +378,4 @@ public static class ServiceCollectionExtensions
throw new AgentNotRegisteredException(agentName);
}
}
private sealed class DefaultDataConverter : DataConverter
{
// Use durable agent options (web defaults + camel case by default) with case-insensitive matching.
// We clone to apply naming/casing tweaks while retaining source-generated metadata where available.
private static readonly JsonSerializerOptions s_options = new(DurableAgentJsonUtilities.DefaultOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override object? Deserialize(string? data, Type targetType)
{
if (data is null)
{
return null;
}
if (targetType == typeof(DurableAgentState))
{
return JsonSerializer.Deserialize(data, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(targetType);
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Deserialize(data, typedInfo);
}
// Fallback (may trigger trimming/AOT warnings for unsupported dynamic types).
return JsonSerializer.Deserialize(data, targetType, s_options);
}
[return: NotNullIfNotNull(nameof(value))]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Fallback path uses reflection when metadata unavailable.")]
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL3050", Justification = "Fallback path uses reflection when metadata unavailable.")]
public override string? Serialize(object? value)
{
if (value is null)
{
return null;
}
if (value is DurableAgentState durableAgentState)
{
return JsonSerializer.Serialize(durableAgentState, DurableAgentStateJsonContext.Default.DurableAgentState);
}
JsonTypeInfo? typeInfo = s_options.GetTypeInfo(value.GetType());
if (typeInfo is JsonTypeInfo typedInfo)
{
return JsonSerializer.Serialize(value, typedInfo);
}
return JsonSerializer.Serialize(value, s_options);
}
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.DurableTask.State;
@@ -28,7 +29,10 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
{
CorrelationId = correlationId,
CreatedAt = response.CreatedAt ?? response.Messages.Max(m => m.CreatedAt) ?? DateTimeOffset.UtcNow,
Messages = response.Messages.Select(DurableAgentStateMessage.FromChatMessage).ToList(),
Messages = response.Messages
.Where(HasSerializableContent)
.Select(DurableAgentStateMessage.FromChatMessage)
.ToList(),
Usage = DurableAgentStateUsage.FromUsage(response.Usage)
};
}
@@ -46,4 +50,18 @@ internal sealed class DurableAgentStateResponse : DurableAgentStateEntry
Usage = this.Usage?.ToUsageDetails(),
};
}
// Checks whether a ChatMessage has any content that will produce meaningful serialized data.
// Known derived AIContent types (TextContent, FunctionCallContent, etc.) are always serializable.
// Base AIContent instances only carry RawRepresentation (which is [JsonIgnore]), Annotations, and
// AdditionalProperties. We keep the message if any base AIContent has annotations or additional
// properties set. NOTE: if AIContent gains new serializable properties in the future, this check
// should be updated accordingly.
private static bool HasSerializableContent(ChatMessage message)
{
return message.Contents.Any(c =>
c.GetType() != typeof(AIContent) ||
c.Annotations?.Count > 0 ||
c.AdditionalProperties?.Count > 0);
}
}
@@ -0,0 +1,177 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Observability;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Executes workflow activities by invoking executor bindings and handling serialization.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Workflow and executor types are registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Workflow and executor types are registered at startup.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")]
internal static class DurableActivityExecutor
{
/// <summary>
/// Executes an activity using the provided executor binding.
/// </summary>
/// <param name="binding">The executor binding to invoke.</param>
/// <param name="input">The serialized input string.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The serialized activity output.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="binding"/> is null.</exception>
/// <exception cref="InvalidOperationException">Thrown when the executor factory is not configured.</exception>
internal static async Task<string> ExecuteAsync(
ExecutorBinding binding,
string input,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(binding);
if (binding.FactoryAsync is null)
{
throw new InvalidOperationException($"Executor binding for '{binding.Id}' does not have a factory configured.");
}
DurableActivityInput? inputWithState = TryDeserializeActivityInput(input);
string executorInput = inputWithState?.Input ?? input;
Dictionary<string, string> sharedState = inputWithState?.State ?? [];
Executor executor = await binding.FactoryAsync(binding.Id).ConfigureAwait(false);
Type inputType = ResolveInputType(inputWithState?.InputTypeName, executor.InputTypes);
object typedInput = DeserializeInput(executorInput, inputType);
DurableWorkflowContext workflowContext = new(sharedState, executor);
object? result = await executor.ExecuteCoreAsync(
typedInput,
new TypeId(inputType),
workflowContext,
WorkflowTelemetryContext.Disabled,
cancellationToken).ConfigureAwait(false);
return SerializeActivityOutput(result, workflowContext);
}
private static string SerializeActivityOutput(object? result, DurableWorkflowContext context)
{
DurableExecutorOutput output = new()
{
Result = SerializeResult(result),
StateUpdates = context.StateUpdates,
ClearedScopes = [.. context.ClearedScopes],
Events = context.OutboundEvents.ConvertAll(SerializeEvent),
SentMessages = context.SentMessages,
HaltRequested = context.HaltRequested
};
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableExecutorOutput);
}
/// <summary>
/// Serializes a workflow event with type information for proper deserialization.
/// </summary>
private static string SerializeEvent(WorkflowEvent evt)
{
Type eventType = evt.GetType();
TypedPayload wrapper = new()
{
TypeName = eventType.AssemblyQualifiedName,
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
};
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
}
private static string SerializeResult(object? result)
{
if (result is null)
{
return string.Empty;
}
if (result is string str)
{
return str;
}
return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options);
}
private static DurableActivityInput? TryDeserializeActivityInput(string input)
{
try
{
return JsonSerializer.Deserialize(input, DurableWorkflowJsonContext.Default.DurableActivityInput);
}
catch (JsonException)
{
return null;
}
}
internal static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
// Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]).
// When the target type is a non-string array, deserialize each element individually.
if (targetType.IsArray && targetType != typeof(string[]))
{
Type elementType = targetType.GetElementType()!;
string[]? stringArray = JsonSerializer.Deserialize<string[]>(input, DurableSerialization.Options);
if (stringArray is not null)
{
Array result = Array.CreateInstance(elementType, stringArray.Length);
for (int i = 0; i < stringArray.Length; i++)
{
object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'.");
result.SetValue(element, i);
}
return result;
}
}
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
internal static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
{
if (string.IsNullOrEmpty(inputTypeName))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
Type? matchedType = supportedTypes.FirstOrDefault(t =>
t.AssemblyQualifiedName == inputTypeName ||
t.FullName == inputTypeName ||
t.Name == inputTypeName);
if (matchedType is not null)
{
return matchedType;
}
Type? loadedType = Type.GetType(inputTypeName);
// Fall back if type is string or string[] but executor doesn't support it
if (loadedType is not null && !supportedTypes.Contains(loadedType))
{
if (loadedType == typeof(string) || loadedType == typeof(string[]))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
}
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
}
}
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Input payload for activity execution, containing the input and other metadata.
/// </summary>
internal sealed class DurableActivityInput
{
/// <summary>
/// Gets or sets the serialized executor input.
/// </summary>
public string? Input { get; set; }
/// <summary>
/// Gets or sets the assembly-qualified type name of the input, used for proper deserialization.
/// </summary>
public string? InputTypeName { get; set; }
/// <summary>
/// Gets or sets the shared state dictionary (scope-prefixed key -> serialized value).
/// </summary>
public Dictionary<string, string> State { get; set; } = [];
}
@@ -0,0 +1,216 @@
// Copyright (c) Microsoft. All rights reserved.
// ConfigureAwait Usage in Orchestration Code:
// This file uses ConfigureAwait(true) because it runs within orchestration context.
// Durable Task orchestrations require deterministic replay - the same code must execute
// identically across replays. ConfigureAwait(true) ensures continuations run on the
// orchestration's synchronization context, which is essential for replay correctness.
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop).
/// </summary>
/// <remarks>
/// Called during the dispatch phase of each superstep by
/// <c>DurableWorkflowRunner.DispatchExecutorsInParallelAsync</c>. For each executor that has
/// pending input, this dispatcher determines whether the executor is an AI agent (stateful,
/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events),
/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the
/// appropriate Durable Task API.
/// The serialised string result is returned to the runner for the routing phase.
/// </remarks>
internal static class DurableExecutorDispatcher
{
/// <summary>
/// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow).
/// </summary>
/// <param name="context">The task orchestration context.</param>
/// <param name="executorInfo">Information about the executor to dispatch.</param>
/// <param name="envelope">The message envelope containing input and type information.</param>
/// <param name="sharedState">The shared state dictionary to pass to the executor.</param>
/// <param name="liveStatus">The live workflow status used to publish events and pending request port state.</param>
/// <param name="logger">The logger for tracing.</param>
/// <returns>The result from the executor.</returns>
internal static async Task<string> DispatchAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
DurableMessageEnvelope envelope,
Dictionary<string, string> sharedState,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
if (executorInfo.IsRequestPortExecutor)
{
return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true);
}
if (executorInfo.IsAgenticExecutor)
{
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
}
if (executorInfo.IsSubworkflowExecutor)
{
return await ExecuteSubWorkflowAsync(context, executorInfo, envelope.Message).ConfigureAwait(true);
}
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true);
}
private static async Task<string> ExecuteActivityAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
string? inputTypeName,
Dictionary<string, string> sharedState)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
DurableActivityInput activityInput = new()
{
Input = input,
InputTypeName = inputTypeName,
State = sharedState
};
string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput);
return await context.CallActivityAsync<string>(activityName, serializedInput).ConfigureAwait(true);
}
/// <summary>
/// Executes a request port executor by waiting for an external event (human-in-the-loop).
/// </summary>
/// <remarks>
/// When the workflow reaches a <see cref="RequestPort"/> executor, the orchestration publishes
/// the pending request to <see cref="DurableWorkflowLiveStatus"/> and waits for an external actor
/// (e.g., a UI or API) to raise the corresponding event via
/// <see cref="IStreamingWorkflowRun.SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>.
/// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep.
/// Each adds its pending request to <see cref="DurableWorkflowLiveStatus.PendingEvents"/>.
/// The wait has no built-in timeout; for time-limited approvals, callers can combine
/// <c>context.CreateTimer</c> with <c>Task.WhenAny</c> in a wrapper executor.
/// </remarks>
private static async Task<string> ExecuteRequestPortAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
RequestPort requestPort = executorInfo.RequestPort!;
string eventName = requestPort.Id;
logger.LogWaitingForExternalEvent(eventName);
// Publish pending request so external clients can discover what input is needed
liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input));
context.SetCustomStatus(liveStatus);
// Wait until the external actor raises the event
string response = await context.WaitForExternalEvent<string>(eventName).ConfigureAwait(true);
// Remove this pending request after receiving the response
liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName);
context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null);
logger.LogReceivedExternalEvent(eventName);
return response;
}
/// <summary>
/// Executes an AI agent executor through Durable Entities.
/// </summary>
/// <remarks>
/// AI agents are stateful and maintain conversation history. They use Durable Entities
/// to persist state across orchestration replays.
/// </remarks>
private static async Task<string> ExecuteAgentAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
ILogger logger,
string input)
{
string agentName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
DurableAIAgent agent = context.GetAgent(agentName);
if (agent is null)
{
logger.LogAgentNotFound(agentName);
return $"Agent '{agentName}' not found";
}
AgentSession session = await agent.CreateSessionAsync().ConfigureAwait(true);
AgentResponse response = await agent.RunAsync(input, session).ConfigureAwait(true);
return response.Text;
}
/// <summary>
/// Dispatches a sub-workflow executor as a sub-orchestration.
/// </summary>
/// <remarks>
/// Sub-workflows run as separate orchestration instances, providing independent
/// checkpointing, replay, and hierarchical visualization in the DTS dashboard.
/// The input is wrapped in <see cref="DurableWorkflowInput{T}"/> so the sub-orchestration
/// can extract it using the same envelope structure. The sub-orchestration returns a
/// <see cref="DurableWorkflowResult"/> directly (deserialized by the Durable Task SDK),
/// which this method converts to a <see cref="DurableExecutorOutput"/> so the parent
/// workflow's result processing picks up both the result and any accumulated events.
/// </remarks>
private static async Task<string> ExecuteSubWorkflowAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input)
{
string orchestrationName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorInfo.SubWorkflow!.Name!);
DurableWorkflowInput<string> workflowInput = new() { Input = input };
DurableWorkflowResult? workflowResult = await context.CallSubOrchestratorAsync<DurableWorkflowResult?>(
orchestrationName,
workflowInput).ConfigureAwait(true);
return ConvertWorkflowResultToExecutorOutput(workflowResult);
}
/// <summary>
/// Converts a <see cref="DurableWorkflowResult"/> from a sub-orchestration
/// into a <see cref="DurableExecutorOutput"/> JSON string. This bridges the sub-workflow's
/// output format to the parent workflow's result processing, preserving both the result
/// and any accumulated events from the sub-workflow.
/// </summary>
private static string ConvertWorkflowResultToExecutorOutput(DurableWorkflowResult? workflowResult)
{
if (workflowResult is null)
{
return string.Empty;
}
// Propagate the result, events, and sent messages from the sub-workflow.
// SentMessages carry the sub-workflow's output for typed routing in the parent,
// matching the in-process WorkflowHostExecutor behavior.
// Shared state is not included because each workflow instance maintains its own
// independent shared state; it is not shared between parent and sub-workflows.
DurableExecutorOutput executorOutput = new()
{
Result = workflowResult.Result,
Events = workflowResult.Events ?? [],
SentMessages = workflowResult.SentMessages ?? [],
HaltRequested = workflowResult.HaltRequested,
};
return JsonSerializer.Serialize(executorOutput, DurableWorkflowJsonContext.Default.DurableExecutorOutput);
}
}
@@ -0,0 +1,39 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Output payload from executor execution, containing the result, state updates, and emitted events.
/// </summary>
internal sealed class DurableExecutorOutput
{
/// <summary>
/// Gets the executor result.
/// </summary>
public string? Result { get; init; }
/// <summary>
/// Gets the state updates (scope-prefixed key to value; null indicates deletion).
/// </summary>
public Dictionary<string, string?> StateUpdates { get; init; } = [];
/// <summary>
/// Gets the scope names that were cleared.
/// </summary>
public List<string> ClearedScopes { get; init; } = [];
/// <summary>
/// Gets the workflow events emitted during execution.
/// </summary>
public List<string> Events { get; init; } = [];
/// <summary>
/// Gets the typed messages sent to downstream executors.
/// </summary>
public List<TypedPayload> SentMessages { get; init; } = [];
/// <summary>
/// Gets a value indicating whether the executor requested a workflow halt.
/// </summary>
public bool HaltRequested { get; init; }
}
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
/// </summary>
public sealed class DurableHaltRequestedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableHaltRequestedEvent"/> class.
/// </summary>
/// <param name="executorId">The ID of the executor that requested the halt.</param>
public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}")
{
this.ExecutorId = executorId;
}
/// <summary>
/// Gets the ID of the executor that requested the halt.
/// </summary>
public string ExecutorId { get; }
}
@@ -0,0 +1,51 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a message envelope for durable workflow message passing.
/// </summary>
/// <remarks>
/// <para>
/// This is the durable equivalent of <c>MessageEnvelope</c> in the in-process runner.
/// Unlike the in-process version which holds native .NET objects, this envelope
/// contains serialized JSON strings suitable for Durable Task activities.
/// </para>
/// </remarks>
internal sealed class DurableMessageEnvelope
{
/// <summary>
/// Gets or sets the serialized JSON message content.
/// </summary>
public required string Message { get; init; }
/// <summary>
/// Gets or sets the full type name of the message for deserialization.
/// </summary>
public string? InputTypeName { get; init; }
/// <summary>
/// Gets or sets the ID of the executor that produced this message.
/// </summary>
/// <remarks>
/// Used for tracing and debugging. Null for initial workflow input.
/// </remarks>
public string? SourceExecutorId { get; init; }
/// <summary>
/// Creates a new message envelope.
/// </summary>
/// <param name="message">The serialized JSON message content.</param>
/// <param name="inputTypeName">The full type name of the message for deserialization.</param>
/// <param name="sourceExecutorId">The ID of the executor that produced this message, or null for initial input.</param>
/// <returns>A new <see cref="DurableMessageEnvelope"/> instance.</returns>
internal static DurableMessageEnvelope Create(string message, string? inputTypeName, string? sourceExecutorId = null)
{
return new DurableMessageEnvelope
{
Message = message,
InputTypeName = inputTypeName,
SourceExecutorId = sourceExecutorId
};
}
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the execution status of a durable workflow run.
/// </summary>
public enum DurableRunStatus
{
/// <summary>
/// The workflow instance was not found.
/// </summary>
NotFound,
/// <summary>
/// The workflow is pending and has not started.
/// </summary>
Pending,
/// <summary>
/// The workflow is currently running.
/// </summary>
Running,
/// <summary>
/// The workflow completed successfully.
/// </summary>
Completed,
/// <summary>
/// The workflow failed with an error.
/// </summary>
Failed,
/// <summary>
/// The workflow was terminated.
/// </summary>
Terminated,
/// <summary>
/// The workflow is suspended.
/// </summary>
Suspended,
/// <summary>
/// The workflow status is unknown.
/// </summary>
Unknown
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Shared serialization options for user-defined workflow types that are not known at compile time
/// and therefore cannot use the source-generated <see cref="DurableWorkflowJsonContext"/>.
/// </summary>
internal static class DurableSerialization
{
/// <summary>
/// Gets the shared <see cref="JsonSerializerOptions"/> for workflow serialization
/// with camelCase naming and case-insensitive deserialization.
/// </summary>
internal static JsonSerializerOptions Options { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}
@@ -0,0 +1,452 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a durable workflow run that supports streaming workflow events as they occur.
/// </summary>
/// <remarks>
/// <para>
/// Events are detected by monitoring the orchestration's custom status at regular intervals.
/// When executors emit events via <see cref="IWorkflowContext.AddEventAsync"/> or
/// <see cref="IWorkflowContext.YieldOutputAsync"/>, they are written to the orchestration's
/// custom status and picked up by this streaming run.
/// </para>
/// <para>
/// When the workflow reaches a <see cref="RequestPort"/> executor, a <see cref="DurableWorkflowWaitingForInputEvent"/>
/// is yielded containing the request data. The caller should then call
/// <see cref="SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>
/// to provide the response and resume the workflow.
/// </para>
/// </remarks>
[DebuggerDisplay("{WorkflowName} ({RunId})")]
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
{
private readonly DurableTaskClient _client;
private readonly Dictionary<string, RequestPort> _requestPorts;
/// <summary>
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
/// </summary>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
/// <param name="workflow">The workflow being executed.</param>
internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow)
{
this._client = client;
this.RunId = instanceId;
this.WorkflowName = workflow.Name ?? string.Empty;
this._requestPorts = ExtractRequestPorts(workflow);
}
/// <inheritdoc/>
public string RunId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName { get; }
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The current status of the durable run.</returns>
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.RunId,
getInputsAndOutputs: false,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
return DurableRunStatus.NotFound;
}
return metadata.RuntimeStatus switch
{
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
_ => DurableRunStatus.Unknown
};
}
/// <inheritdoc/>
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
/// <summary>
/// Asynchronously streams workflow events as they occur during workflow execution.
/// </summary>
/// <param name="pollingInterval">The interval between status checks. Defaults to 100ms.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
private async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
TimeSpan? pollingInterval,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
TimeSpan minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
TimeSpan currentInterval = minInterval;
// Track how many events we've already read from the durable workflow status
int lastReadEventIndex = 0;
// Track which pending events we've already yielded to avoid duplicates
HashSet<string> yieldedPendingEvents = [];
while (!cancellationToken.IsCancellationRequested)
{
// Poll with getInputsAndOutputs: true because SerializedCustomStatus
// (used for event streaming) is only populated when this flag is set.
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.RunId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
yield break;
}
bool hasNewEvents = false;
// Always drain any unread events from the durable workflow status before checking terminal states.
// The orchestration may complete before the next poll, so events would be lost if we
// check terminal status first.
if (metadata.SerializedCustomStatus is not null)
{
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
hasNewEvents = true;
yield return evt;
}
// Yield a DurableWorkflowWaitingForInputEvent for each new pending request port
foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents)
{
if (yieldedPendingEvents.Add(pending.EventName))
{
if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort))
{
// RequestPort may not exist in the current workflow definition (e.g., during rolling deployments).
continue;
}
hasNewEvents = true;
yield return new DurableWorkflowWaitingForInputEvent(
pending.Input,
matchingPort);
}
}
// Sync tracking with current pending events so re-used RequestPort names can be yielded again
if (liveStatus.PendingEvents.Count == 0)
{
yieldedPendingEvents.Clear();
}
else
{
yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName));
}
}
}
// Check terminal states after draining events from the durable workflow status
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
// The framework clears the durable workflow status on completion, so events may be in
// SerializedOutput as a DurableWorkflowResult wrapper.
if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult))
{
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
yield return evt;
}
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
}
else
{
// The runner always wraps output in DurableWorkflowResult, so a parse
// failure here indicates a bug. Yield a failed event so the consumer
// gets a visible, handleable signal without crashing.
yield return new DurableWorkflowFailedEvent(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) completed but its output could not be parsed as DurableWorkflowResult.");
}
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
yield return new DurableWorkflowFailedEvent(errorMessage, metadata.FailureDetails);
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
{
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
yield break;
}
// Adaptive backoff: reset to minimum when events were found, increase otherwise
currentInterval = hasNewEvents
? minInterval
: TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds));
try
{
await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
yield break;
}
}
}
/// <summary>
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
/// </summary>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public async ValueTask SendResponseAsync<TResponse>(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestEvent);
string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options);
await this._client.RaiseEventAsync(
this.RunId,
requestEvent.RequestPort.Id,
serializedResponse,
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The result of the workflow execution.</returns>
/// <exception cref="TaskFailedException">Thrown when the workflow failed.</exception>
/// <exception cref="InvalidOperationException">Thrown when the workflow was terminated or ended with an unexpected status.</exception>
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
{
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
this.RunId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
return ExtractResult<TResult>(metadata.SerializedOutput);
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
if (metadata.FailureDetails is not null)
{
throw new TaskFailedException(
taskName: this.WorkflowName,
taskId: -1,
failureDetails: metadata.FailureDetails);
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details.");
}
throw new InvalidOperationException(
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}");
}
/// <summary>
/// Deserializes and returns any events beyond <paramref name="lastReadIndex"/> from the list.
/// </summary>
private static (List<WorkflowEvent> Events, int UpdatedIndex) DrainNewEvents(List<string> serializedEvents, int lastReadIndex)
{
List<WorkflowEvent> events = [];
while (lastReadIndex < serializedEvents.Count)
{
string serializedEvent = serializedEvents[lastReadIndex];
lastReadIndex++;
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
if (workflowEvent is not null)
{
events.Add(workflowEvent);
}
}
return (events, lastReadIndex);
}
/// <summary>
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
/// <remarks>
/// The orchestration returns a <see cref="DurableWorkflowResult"/> object directly.
/// The Durable Task framework's <c>DataConverter</c> serializes it as a JSON object
/// in <c>SerializedOutput</c>, so we deserialize it directly.
/// </remarks>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhen(true)] out DurableWorkflowResult? result)
{
if (serializedOutput is null)
{
result = default!;
return false;
}
try
{
result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!;
return result is not null;
}
catch (JsonException)
{
result = default!;
return false;
}
}
/// <summary>
/// Extracts a typed result from the orchestration output by unwrapping the
/// <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
{
if (serializedOutput is null)
{
return default;
}
if (!TryParseWorkflowResult(serializedOutput, out DurableWorkflowResult? workflowResult))
{
throw new InvalidOperationException(
"Failed to parse orchestration output as DurableWorkflowResult. " +
"The orchestration runner should always wrap output in this format.");
}
string? resultJson = workflowResult.Result;
if (resultJson is null)
{
return default;
}
if (typeof(TResult) == typeof(string))
{
return (TResult)(object)resultJson;
}
return JsonSerializer.Deserialize<TResult>(resultJson, DurableSerialization.Options);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")]
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
{
try
{
TypedPayload? wrapper = JsonSerializer.Deserialize(
serializedEvent,
DurableWorkflowJsonContext.Default.TypedPayload);
if (wrapper?.TypeName is not null && wrapper.Data is not null)
{
Type? eventType = Type.GetType(wrapper.TypeName);
if (eventType is not null)
{
return DeserializeEventByType(eventType, wrapper.Data);
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
{
// Types with internal constructors need manual deserialization
if (eventType == typeof(ExecutorInvokedEvent)
|| eventType == typeof(ExecutorCompletedEvent)
|| eventType == typeof(WorkflowOutputEvent))
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (eventType == typeof(ExecutorInvokedEvent))
{
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorInvokedEvent(executorId, data!);
}
if (eventType == typeof(ExecutorCompletedEvent))
{
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorCompletedEvent(executorId, data);
}
// WorkflowOutputEvent
string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty;
object? outputData = GetDataProperty(root);
return new WorkflowOutputEvent(outputData!, sourceId);
}
return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent;
}
private static JsonElement? GetDataProperty(JsonElement root)
{
if (!root.TryGetProperty("data", out JsonElement dataElement))
{
return null;
}
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
}
private static Dictionary<string, RequestPort> ExtractRequestPorts(Workflow workflow)
{
return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow)
.Where(e => e.RequestPort is not null)
.ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!);
}
}
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides a durable task-based implementation of <see cref="IWorkflowClient"/> for running
/// workflows as durable orchestrations.
/// </summary>
internal sealed class DurableWorkflowClient : IWorkflowClient
{
private readonly DurableTaskClient _client;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowClient"/> class.
/// </summary>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="client"/> is null.</exception>
public DurableWorkflowClient(DurableTaskClient client)
{
ArgumentNullException.ThrowIfNull(client);
this._client = client;
}
/// <inheritdoc/>
public async ValueTask<IWorkflowRun> RunAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
{
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
input: workflowInput,
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
cancellation: cancellationToken).ConfigureAwait(false);
return new DurableWorkflowRun(this._client, instanceId, workflow.Name);
}
/// <inheritdoc/>
public ValueTask<IWorkflowRun> RunAsync(
Workflow workflow,
string input,
string? runId = null,
CancellationToken cancellationToken = default)
=> this.RunAsync<string>(workflow, input, runId, cancellationToken);
/// <inheritdoc/>
public async ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
{
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
input: workflowInput,
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
cancellation: cancellationToken).ConfigureAwait(false);
return new DurableStreamingWorkflowRun(this._client, instanceId, workflow);
}
/// <inheritdoc/>
public ValueTask<IStreamingWorkflowRun> StreamAsync(
Workflow workflow,
string input,
string? runId = null,
CancellationToken cancellationToken = default)
=> this.StreamAsync<string>(workflow, input, runId, cancellationToken);
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when a durable workflow completes successfully.
/// </summary>
[DebuggerDisplay("Completed: {Result}")]
public sealed class DurableWorkflowCompletedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowCompletedEvent"/> class.
/// </summary>
/// <param name="result">The serialized result of the workflow.</param>
public DurableWorkflowCompletedEvent(string? result) : base(result)
{
this.Result = result;
}
/// <summary>
/// Gets the serialized result of the workflow.
/// </summary>
public string? Result { get; }
}
@@ -0,0 +1,327 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// A workflow context for durable workflow execution.
/// </summary>
/// <remarks>
/// State is passed in from the orchestration and updates are collected for return.
/// Events emitted during execution are collected and returned to the orchestration
/// as part of the activity output for streaming to callers.
/// </remarks>
[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")]
internal sealed class DurableWorkflowContext : IWorkflowContext
{
/// <summary>
/// The default scope name used when no explicit scope is specified.
/// Scopes partition shared state into logical namespaces so that different
/// parts of a workflow can manage their state keys independently.
/// </summary>
private const string DefaultScopeName = "__default__";
private readonly Dictionary<string, string> _initialState;
private readonly Executor _executor;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowContext"/> class.
/// </summary>
/// <param name="initialState">The shared state passed from the orchestration.</param>
/// <param name="executor">The executor running in this context.</param>
internal DurableWorkflowContext(Dictionary<string, string>? initialState, Executor executor)
{
this._executor = executor;
this._initialState = initialState ?? [];
}
/// <summary>
/// Gets the messages sent during activity execution via <see cref="SendMessageAsync"/>.
/// </summary>
internal List<TypedPayload> SentMessages { get; } = [];
/// <summary>
/// Gets the outbound events that were added during activity execution.
/// </summary>
internal List<WorkflowEvent> OutboundEvents { get; } = [];
/// <summary>
/// Gets the state updates made during activity execution.
/// </summary>
internal Dictionary<string, string?> StateUpdates { get; } = [];
/// <summary>
/// Gets the scopes that were cleared during activity execution.
/// </summary>
internal HashSet<string> ClearedScopes { get; } = [];
/// <summary>
/// Gets a value indicating whether the executor requested a workflow halt.
/// </summary>
internal bool HaltRequested { get; private set; }
/// <inheritdoc/>
public ValueTask AddEventAsync(
WorkflowEvent workflowEvent,
CancellationToken cancellationToken = default)
{
if (workflowEvent is not null)
{
this.OutboundEvents.Add(workflowEvent);
}
return default;
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow message types registered at startup.")]
public ValueTask SendMessageAsync(
object message,
string? targetId = null,
CancellationToken cancellationToken = default)
{
if (message is not null)
{
Type messageType = message.GetType();
this.SentMessages.Add(new TypedPayload
{
Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options),
TypeName = messageType.AssemblyQualifiedName
});
}
return default;
}
/// <inheritdoc/>
public ValueTask YieldOutputAsync(
object output,
CancellationToken cancellationToken = default)
{
if (output is not null)
{
Type outputType = output.GetType();
if (!this._executor.CanOutput(outputType))
{
throw new InvalidOperationException(
$"Cannot output object of type {outputType.Name}. " +
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
}
this.OutboundEvents.Add(new WorkflowOutputEvent(output, this._executor.Id));
}
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync()
{
this.HaltRequested = true;
this.OutboundEvents.Add(new DurableHaltRequestedEvent(this._executor.Id));
return default;
}
/// <inheritdoc/>
public ValueTask<T?> ReadStateAsync<T>(
string key,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(key);
string scopeKey = GetScopeKey(scopeName, key);
string normalizedScope = scopeName ?? DefaultScopeName;
bool scopeCleared = this.ClearedScopes.Contains(normalizedScope);
// Local updates take priority over initial state.
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
return DeserializeStateAsync<T>(updated);
}
// If scope was cleared, ignore initial state
if (scopeCleared)
{
return ValueTask.FromResult<T?>(default);
}
// Fall back to initial state passed from orchestration
if (this._initialState.TryGetValue(scopeKey, out string? initial))
{
return DeserializeStateAsync<T>(initial);
}
return ValueTask.FromResult<T?>(default);
}
/// <inheritdoc/>
public async ValueTask<T> ReadOrInitStateAsync<T>(
string key,
Func<T> initialStateFactory,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(key);
ArgumentNullException.ThrowIfNull(initialStateFactory);
// Cannot rely on `value is not null` because T? on an unconstrained generic
// parameter does not become Nullable<T> for value types — the null check is
// always true for types like int. Instead, check key existence directly.
if (this.HasStateKey(key, scopeName))
{
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
if (value is not null)
{
return value;
}
}
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
/// <inheritdoc/>
public ValueTask<HashSet<string>> ReadStateKeysAsync(
string? scopeName = null,
CancellationToken cancellationToken = default)
{
string scopePrefix = GetScopePrefix(scopeName);
int scopePrefixLength = scopePrefix.Length;
HashSet<string> keys = new(StringComparer.Ordinal);
bool scopeCleared = scopeName is null
? this.ClearedScopes.Contains(DefaultScopeName)
: this.ClearedScopes.Contains(scopeName);
// Start with keys from initial state (skip if scope was cleared)
if (!scopeCleared)
{
foreach (string stateKey in this._initialState.Keys)
{
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keys.Add(stateKey[scopePrefixLength..]);
}
}
}
// Merge local updates: add if non-null, remove if null (deleted)
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
continue;
}
string key = update.Key[scopePrefixLength..];
if (update.Value is not null)
{
keys.Add(key);
}
else
{
keys.Remove(key);
}
}
return ValueTask.FromResult(keys);
}
/// <inheritdoc/>
public ValueTask QueueStateUpdateAsync<T>(
string key,
T? value,
string? scopeName = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrEmpty(key);
string scopeKey = GetScopeKey(scopeName, key);
this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value);
return default;
}
/// <inheritdoc/>
public ValueTask QueueClearScopeAsync(
string? scopeName = null,
CancellationToken cancellationToken = default)
{
this.ClearedScopes.Add(scopeName ?? DefaultScopeName);
// Remove any pending updates in this scope (snapshot keys to allow removal during iteration)
string scopePrefix = GetScopePrefix(scopeName);
foreach (string key in this.StateUpdates.Keys.ToList())
{
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
this.StateUpdates.Remove(key);
}
}
return default;
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string>? TraceContext => null;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => false;
private static string GetScopeKey(string? scopeName, string key)
=> $"{GetScopePrefix(scopeName)}{key}";
/// <summary>
/// Checks whether the given key exists in local updates or initial state,
/// respecting cleared scopes.
/// </summary>
private bool HasStateKey(string key, string? scopeName)
{
string scopeKey = GetScopeKey(scopeName, key);
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
return updated is not null;
}
string normalizedScope = scopeName ?? DefaultScopeName;
if (this.ClearedScopes.Contains(normalizedScope))
{
return false;
}
return this._initialState.ContainsKey(scopeKey);
}
/// <summary>
/// Returns the key prefix for the given scope. Scopes partition shared state
/// into logical namespaces, allowing different workflow executors to manage
/// their state keys independently. When no scope is specified, the
/// <see cref="DefaultScopeName"/> is used.
/// </summary>
private static string GetScopePrefix(string? scopeName)
=> scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:";
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
private static string SerializeState<T>(T value)
=> JsonSerializer.Serialize(value, DurableSerialization.Options);
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
private static ValueTask<T?> DeserializeStateAsync<T>(string? json)
{
if (json is null)
{
return ValueTask.FromResult<T?>(default);
}
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(json, DurableSerialization.Options));
}
}
@@ -0,0 +1,35 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when a durable workflow fails.
/// </summary>
[DebuggerDisplay("Failed: {ErrorMessage}")]
public sealed class DurableWorkflowFailedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowFailedEvent"/> class.
/// </summary>
/// <param name="errorMessage">The error message describing the failure.</param>
/// <param name="failureDetails">The full failure details from the Durable Task runtime, if available.</param>
public DurableWorkflowFailedEvent(string errorMessage, TaskFailureDetails? failureDetails = null) : base(errorMessage)
{
this.ErrorMessage = errorMessage;
this.FailureDetails = failureDetails;
}
/// <summary>
/// Gets the error message describing the failure.
/// </summary>
public string ErrorMessage { get; }
/// <summary>
/// Gets the full failure details from the Durable Task runtime, including error type, stack trace, and inner failure.
/// </summary>
public TaskFailureDetails? FailureDetails { get; }
}
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the input envelope for a durable workflow orchestration.
/// </summary>
/// <typeparam name="TInput">The type of the workflow input.</typeparam>
internal sealed class DurableWorkflowInput<TInput>
where TInput : notnull
{
/// <summary>
/// Gets the workflow input data.
/// </summary>
public required TInput Input { get; init; }
}
@@ -0,0 +1,41 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Source-generated JSON serialization context for durable workflow types.
/// </summary>
/// <remarks>
/// <para>
/// This context provides AOT-compatible and trimmer-safe JSON serialization for the
/// internal data transfer types used by the durable workflow infrastructure:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
/// <item><description><see cref="DurableExecutorOutput"/>: Executor output wrapper with results, events, and state updates</description></item>
/// <item><description><see cref="TypedPayload"/>: Serialized payload wrapper with type info (events and messages)</description></item>
/// <item><description><see cref="DurableWorkflowLiveStatus"/>: Live status payload (streaming events and pending request ports)</description></item>
/// </list>
/// <para>
/// Note: User-defined executor input/output types still use reflection-based serialization
/// since their types are not known at compile time.
/// </para>
/// </remarks>
[JsonSourceGenerationOptions(
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DurableActivityInput))]
[JsonSerializable(typeof(DurableExecutorOutput))]
[JsonSerializable(typeof(TypedPayload))]
[JsonSerializable(typeof(List<TypedPayload>))]
[JsonSerializable(typeof(DurableWorkflowLiveStatus))]
[JsonSerializable(typeof(DurableWorkflowResult))]
[JsonSerializable(typeof(PendingRequestPortStatus))]
[JsonSerializable(typeof(List<PendingRequestPortStatus>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
internal partial class DurableWorkflowJsonContext : JsonSerializerContext;
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Live status payload written to the orchestration via <c>SetCustomStatus</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the only orchestration state readable by external clients while the workflow
/// is still running. It is written after each superstep so that
/// <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
/// On completion the framework clears it, so events are also
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
/// </para>
/// <para>
/// When the workflow is paused at one or more <see cref="RequestPort"/> nodes,
/// <see cref="PendingEvents"/> contains the request data for each.
/// </para>
/// </remarks>
internal sealed class DurableWorkflowLiveStatus
{
/// <summary>
/// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed.
/// </summary>
public List<PendingRequestPortStatus> PendingEvents { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted so far.
/// </summary>
public List<string> Events { get; set; } = [];
/// <summary>
/// Attempts to deserialize a serialized custom status string into a <see cref="DurableWorkflowLiveStatus"/>.
/// </summary>
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")]
internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result)
{
if (serializedStatus is null)
{
result = default!;
return false;
}
try
{
result = System.Text.Json.JsonSerializer.Deserialize<DurableWorkflowLiveStatus>(serializedStatus, DurableSerialization.Options)!;
return result is not null;
}
catch (System.Text.Json.JsonException)
{
result = default!;
return false;
}
}
}
@@ -0,0 +1,111 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Provides configuration options for managing durable workflows within an application.
/// </summary>
[DebuggerDisplay("Workflows = {Workflows.Count}")]
public sealed class DurableWorkflowOptions
{
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
/// </summary>
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
{
this.ParentOptions = parentOptions;
}
/// <summary>
/// Gets the parent <see cref="DurableOptions"/> container, if available.
/// </summary>
internal DurableOptions? ParentOptions { get; }
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
public IReadOnlyDictionary<string, Workflow> Workflows => this._workflows;
/// <summary>
/// Gets the executor registry for direct executor lookup.
/// </summary>
internal ExecutorRegistry Executors { get; } = new();
/// <summary>
/// Adds a workflow to the collection for processing or execution.
/// </summary>
/// <param name="workflow">The workflow instance to add. Cannot be null.</param>
/// <remarks>
/// When a workflow is added, all executors are registered in the executor registry.
/// Any AI agent executors will also be automatically registered with the
/// <see cref="DurableAgentsOptions"/> if available.
/// </remarks>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflow"/> is null.</exception>
/// <exception cref="ArgumentException">Thrown when the workflow does not have a valid name.</exception>
public void AddWorkflow(Workflow workflow)
{
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
this._workflows[workflow.Name] = workflow;
this.RegisterWorkflowExecutors(workflow);
}
/// <summary>
/// Adds a collection of workflows to the current instance.
/// </summary>
/// <param name="workflows">The collection of <see cref="Workflow"/> objects to add.</param>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="workflows"/> is null.</exception>
public void AddWorkflows(params Workflow[] workflows)
{
ArgumentNullException.ThrowIfNull(workflows);
foreach (Workflow workflow in workflows)
{
this.AddWorkflow(workflow);
}
}
/// <summary>
/// Registers all executors from a workflow, including AI agents if agent options are available.
/// </summary>
private void RegisterWorkflowExecutors(Workflow workflow)
{
DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents;
foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors())
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorId);
this.Executors.Register(executorName, executorId, workflow);
TryRegisterAgent(binding, agentOptions);
}
}
/// <summary>
/// Registers an AI agent with the agent options if the binding contains an unregistered agent.
/// </summary>
private static void TryRegisterAgent(ExecutorBinding binding, DurableAgentsOptions? agentOptions)
{
if (agentOptions is null)
{
return;
}
if (binding.RawValue is AIAgent { Name: not null } agent
&& !agentOptions.ContainsAgent(agent.Name))
{
agentOptions.AddAIAgent(agent);
}
}
}

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