mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26cd5cc1bf | ||
|
|
b4c4f5094e | ||
|
|
0cd40f8354 | ||
|
|
cefda44283 | ||
|
|
4afc088f01 | ||
|
|
1272ec5adf | ||
|
|
47ead84753 | ||
|
|
4c287c2424 | ||
|
|
100086a276 | ||
|
|
fc6721ca8e | ||
|
|
4b21f38650 | ||
|
|
bf8d9672e1 | ||
|
|
5374dd47c5 | ||
|
|
29dfcbb584 | ||
|
|
c9321b9028 | ||
|
|
f48c4512d3 | ||
|
|
d3d0100822 | ||
|
|
acaf6b7054 | ||
|
|
c2fec6b51c | ||
|
|
705ed47a0b | ||
|
|
192a283c9a | ||
|
|
c74b1b08eb | ||
|
|
7c85f98c27 | ||
|
|
1e6f8909ec | ||
|
|
008fe23585 | ||
|
|
6af0511e2b | ||
|
|
6dbb0a5bb4 | ||
|
|
21af304c7d | ||
|
|
94af83680e | ||
|
|
cdb51e6a41 | ||
|
|
cbcdb2d29e | ||
|
|
0fdcfd0f4c | ||
|
|
414496dda7 | ||
|
|
55011b7258 | ||
|
|
bf0af178bd |
@@ -3,6 +3,7 @@
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scan open issues and PRs for stale follow-ups from external authors.
|
||||
|
||||
If a team member commented and the external author hasn't replied within
|
||||
DAYS_THRESHOLD days, post a reminder comment and add the 'needs-info' label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from github import Auth, Github, GithubException
|
||||
from github.Issue import Issue
|
||||
from github.IssueComment import IssueComment
|
||||
|
||||
|
||||
PING_COMMENT = (
|
||||
"@{author}, friendly reminder — this issue is waiting on your response. "
|
||||
"Please share any updates when you get a chance. (This is an automated message.)"
|
||||
)
|
||||
LABEL = "needs-info"
|
||||
|
||||
|
||||
def get_team_members(g: Github, org: str, team_slug: str) -> set[str]:
|
||||
"""Fetch active team member usernames."""
|
||||
try:
|
||||
org_obj = g.get_organization(org)
|
||||
team = org_obj.get_team_by_slug(team_slug)
|
||||
return {m.login for m in team.get_members()}
|
||||
except GithubException as exc:
|
||||
if exc.status in (403, 404):
|
||||
print(
|
||||
f"ERROR: Failed to fetch team members for {org}/{team_slug} "
|
||||
f"(HTTP {exc.status}). Check that the token has the 'read:org' "
|
||||
f"scope and that the team slug '{team_slug}' is correct."
|
||||
)
|
||||
else:
|
||||
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_last_team_comment(
|
||||
comments: list[IssueComment], team_members: set[str]
|
||||
) -> IssueComment | None:
|
||||
"""Return the most recent comment from a team member, or None."""
|
||||
for comment in reversed(comments):
|
||||
if comment.user and comment.user.login in team_members:
|
||||
return comment
|
||||
return None
|
||||
|
||||
|
||||
def author_replied_after(
|
||||
comments: list[IssueComment], author: str, after: datetime
|
||||
) -> bool:
|
||||
"""Check if the issue author commented after the given timestamp."""
|
||||
for comment in comments:
|
||||
if (
|
||||
comment.user
|
||||
and comment.user.login == author
|
||||
and comment.created_at > after
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_ping(
|
||||
issue: Issue,
|
||||
team_members: set[str],
|
||||
days_threshold: int,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Determine whether this issue/PR should be pinged."""
|
||||
author = issue.user.login
|
||||
|
||||
# Skip if author is a team member
|
||||
if author in team_members:
|
||||
return False
|
||||
|
||||
# Skip if already labeled
|
||||
if any(label.name == LABEL for label in issue.labels):
|
||||
return False
|
||||
|
||||
# Skip if no comments at all
|
||||
if issue.comments == 0:
|
||||
return False
|
||||
|
||||
# Fetch comments once for both lookups
|
||||
comments = list(issue.get_comments())
|
||||
|
||||
# Find last team member comment
|
||||
last_team_comment = find_last_team_comment(comments, team_members)
|
||||
if last_team_comment is None:
|
||||
return False
|
||||
|
||||
# Skip if author replied after the last team comment
|
||||
if author_replied_after(comments, author, last_team_comment.created_at):
|
||||
return False
|
||||
|
||||
# Check if enough days have passed
|
||||
days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days
|
||||
if days_since < days_threshold:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ping(issue: Issue, dry_run: bool) -> bool:
|
||||
"""Post a reminder comment and add the needs-info label. Returns True on success."""
|
||||
author = issue.user.login
|
||||
kind = "PR" if issue.pull_request else "Issue"
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})")
|
||||
return True
|
||||
|
||||
max_retries = 3
|
||||
commented = False
|
||||
labeled = False
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
if not commented:
|
||||
issue.create_comment(PING_COMMENT.format(author=author))
|
||||
commented = True
|
||||
if not labeled:
|
||||
issue.add_to_labels(LABEL)
|
||||
labeled = True
|
||||
print(f" Pinged {kind} #{issue.number} (@{author})")
|
||||
return True
|
||||
except Exception as exc:
|
||||
if attempt < max_retries:
|
||||
wait = 2 ** attempt # 2s, 4s
|
||||
print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if not token:
|
||||
print("ERROR: GITHUB_TOKEN environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
repository = os.environ.get("GITHUB_REPOSITORY")
|
||||
if not repository:
|
||||
print("ERROR: GITHUB_REPOSITORY environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
team_slug = os.environ.get("TEAM_SLUG")
|
||||
if not team_slug:
|
||||
print("ERROR: TEAM_SLUG environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4")
|
||||
try:
|
||||
days_threshold = int(days_threshold_raw)
|
||||
except ValueError:
|
||||
print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'")
|
||||
sys.exit(1)
|
||||
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
|
||||
|
||||
org = repository.split("/")[0]
|
||||
|
||||
if dry_run:
|
||||
print("Running in DRY RUN mode — no comments or labels will be applied.\n")
|
||||
|
||||
g = Github(auth=Auth.Token(token))
|
||||
repo = g.get_repo(repository)
|
||||
|
||||
print(f"Fetching team members for {org}/{team_slug}...")
|
||||
team_members = get_team_members(g, org, team_slug)
|
||||
print(f"Found {len(team_members)} team members.\n")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
pinged = []
|
||||
failed = []
|
||||
scanned = 0
|
||||
|
||||
print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n")
|
||||
|
||||
for issue in repo.get_issues(state="open"):
|
||||
scanned += 1
|
||||
|
||||
if should_ping(issue, team_members, days_threshold, now):
|
||||
if ping(issue, dry_run):
|
||||
pinged.append(issue.number)
|
||||
else:
|
||||
failed.append(issue.number)
|
||||
|
||||
print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.")
|
||||
if pinged:
|
||||
print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}")
|
||||
if failed:
|
||||
print(f"Failed: {', '.join(f'#{n}' for n in failed)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,293 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for stale_issue_pr_ping.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the script directory is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
|
||||
from stale_issue_pr_ping import (
|
||||
LABEL,
|
||||
PING_COMMENT,
|
||||
author_replied_after,
|
||||
find_last_team_comment,
|
||||
get_team_members,
|
||||
main,
|
||||
ping,
|
||||
should_ping,
|
||||
)
|
||||
|
||||
TEAM = {"alice", "bob"}
|
||||
NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_comment(login: str | None, created_at: datetime) -> MagicMock:
|
||||
"""Create a mock IssueComment."""
|
||||
c = MagicMock()
|
||||
if login is None:
|
||||
c.user = None
|
||||
else:
|
||||
c.user = MagicMock()
|
||||
c.user.login = login
|
||||
c.created_at = created_at
|
||||
return c
|
||||
|
||||
|
||||
def _make_label(name: str) -> MagicMock:
|
||||
lbl = MagicMock()
|
||||
lbl.name = name
|
||||
return lbl
|
||||
|
||||
|
||||
def _make_issue(
|
||||
author: str = "external",
|
||||
labels: list[str] | None = None,
|
||||
comment_count: int = 1,
|
||||
comments: list[MagicMock] | None = None,
|
||||
pull_request: bool = False,
|
||||
number: int = 42,
|
||||
) -> MagicMock:
|
||||
issue = MagicMock()
|
||||
issue.user = MagicMock()
|
||||
issue.user.login = author
|
||||
issue.number = number
|
||||
issue.labels = [_make_label(n) for n in (labels or [])]
|
||||
issue.comments = comment_count
|
||||
issue.pull_request = MagicMock() if pull_request else None
|
||||
if comments is not None:
|
||||
issue.get_comments.return_value = comments
|
||||
return issue
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_last_team_comment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindLastTeamComment:
|
||||
def test_returns_last_team_comment(self):
|
||||
c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1, c2, c3], TEAM) is c3
|
||||
|
||||
def test_returns_none_when_no_team_comments(self):
|
||||
c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1], TEAM) is None
|
||||
|
||||
def test_returns_none_for_empty_list(self):
|
||||
assert find_last_team_comment([], TEAM) is None
|
||||
|
||||
def test_skips_deleted_user(self):
|
||||
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1, c2], TEAM) is c2
|
||||
|
||||
def test_only_deleted_users(self):
|
||||
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1], TEAM) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# author_replied_after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuthorRepliedAfter:
|
||||
def test_author_replied(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is True
|
||||
|
||||
def test_author_not_replied(self):
|
||||
after = datetime(2026, 3, 5, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
def test_different_user_replied(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
def test_deleted_user_comment(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_ping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldPing:
|
||||
def test_should_ping_stale_issue(self):
|
||||
team_comment = _make_comment("alice", NOW - timedelta(days=5))
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is True
|
||||
|
||||
def test_skip_team_member_author(self):
|
||||
issue = _make_issue(author="alice", comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_already_labeled(self):
|
||||
issue = _make_issue(labels=[LABEL], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_no_comments(self):
|
||||
issue = _make_issue(comment_count=0)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_no_team_comment(self):
|
||||
c = _make_comment("external", NOW - timedelta(days=5))
|
||||
issue = _make_issue(comments=[c], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_author_replied(self):
|
||||
team_c = _make_comment("alice", NOW - timedelta(days=5))
|
||||
author_c = _make_comment("external", NOW - timedelta(days=3))
|
||||
issue = _make_issue(comments=[team_c, author_c], comment_count=2)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_not_enough_days(self):
|
||||
team_comment = _make_comment("alice", NOW - timedelta(days=2))
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_aware_datetime_handled(self):
|
||||
"""Timezone-aware datetimes should not be mangled by astimezone."""
|
||||
aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc)
|
||||
team_comment = _make_comment("alice", aware_dt)
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is True
|
||||
|
||||
def test_naive_datetime_handled(self):
|
||||
"""Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone."""
|
||||
naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None)
|
||||
team_comment = _make_comment("alice", naive_dt)
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
# astimezone on naive datetime treats it as local time; just verify no crash
|
||||
should_ping(issue, TEAM, 4, NOW)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPing:
|
||||
def test_dry_run(self, capsys):
|
||||
issue = _make_issue()
|
||||
assert ping(issue, dry_run=True) is True
|
||||
issue.create_comment.assert_not_called()
|
||||
assert "DRY RUN" in capsys.readouterr().out
|
||||
|
||||
def test_success(self, capsys):
|
||||
issue = _make_issue()
|
||||
assert ping(issue, dry_run=False) is True
|
||||
issue.create_comment.assert_called_once()
|
||||
issue.add_to_labels.assert_called_once_with(LABEL)
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_retry_on_failure(self, mock_sleep):
|
||||
issue = _make_issue()
|
||||
issue.create_comment.side_effect = [Exception("net error"), None]
|
||||
assert ping(issue, dry_run=False) is True
|
||||
assert issue.create_comment.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep):
|
||||
"""If create_comment succeeds but add_to_labels fails, retry should not re-comment."""
|
||||
issue = _make_issue()
|
||||
issue.add_to_labels.side_effect = [Exception("label error"), None]
|
||||
assert ping(issue, dry_run=False) is True
|
||||
# Comment should only be created once even though there were 2 attempts
|
||||
assert issue.create_comment.call_count == 1
|
||||
assert issue.add_to_labels.call_count == 2
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_all_retries_fail(self, mock_sleep):
|
||||
issue = _make_issue()
|
||||
issue.create_comment.side_effect = Exception("permanent error")
|
||||
assert ping(issue, dry_run=False) is False
|
||||
assert issue.create_comment.call_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_team_members
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTeamMembers:
|
||||
def test_success(self):
|
||||
g = MagicMock()
|
||||
member = MagicMock()
|
||||
member.login = "alice"
|
||||
g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member]
|
||||
assert get_team_members(g, "org", "my-team") == {"alice"}
|
||||
|
||||
def test_403_error_message(self, capsys):
|
||||
from github import GithubException
|
||||
|
||||
g = MagicMock()
|
||||
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
|
||||
403, {"message": "Forbidden"}, None
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "my-team")
|
||||
out = capsys.readouterr().out
|
||||
assert "read:org" in out
|
||||
assert "403" in out
|
||||
|
||||
def test_404_error_message(self, capsys):
|
||||
from github import GithubException
|
||||
|
||||
g = MagicMock()
|
||||
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
|
||||
404, {"message": "Not Found"}, None
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "bad-slug")
|
||||
out = capsys.readouterr().out
|
||||
assert "read:org" in out
|
||||
assert "bad-slug" in out
|
||||
|
||||
def test_generic_error(self, capsys):
|
||||
g = MagicMock()
|
||||
g.get_organization.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "team")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main – env var validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMain:
|
||||
@patch.dict(os.environ, {
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GITHUB_REPOSITORY": "org/repo",
|
||||
"TEAM_SLUG": "my-team",
|
||||
"DAYS_THRESHOLD": "abc",
|
||||
}, clear=True)
|
||||
def test_invalid_days_threshold(self, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
assert "numeric" in capsys.readouterr().out
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GITHUB_REPOSITORY": "org/repo",
|
||||
}, clear=True)
|
||||
def test_missing_team_slug(self, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
assert "TEAM_SLUG" in capsys.readouterr().out
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run fmt, lint, pyright in parallel across packages
|
||||
- name: Run syntax and pyright across packages
|
||||
run: uv run poe check-packages
|
||||
|
||||
samples-markdown:
|
||||
@@ -104,10 +104,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run samples lint
|
||||
run: uv run poe samples-lint
|
||||
- name: Run samples syntax check
|
||||
run: uv run poe samples-syntax
|
||||
- name: Run samples checks
|
||||
run: uv run poe check -S
|
||||
- name: Run markdown code lint
|
||||
run: uv run poe markdown-code-lint
|
||||
|
||||
@@ -140,4 +138,4 @@ jobs:
|
||||
- name: Run Mypy
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
|
||||
run: uv run poe ci-mypy
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
|
||||
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
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 "*"
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
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 "*"`
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
@@ -48,9 +48,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -32,17 +32,17 @@ jobs:
|
||||
id: python-setup
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run all tests with coverage report
|
||||
run: uv run poe all-tests-cov --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
run: uv run poe test -A -C --cov-report=xml:python-coverage.xml -q --junitxml=pytest.xml
|
||||
- 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
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
run: uv run poe test -A
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Stale issue and PR ping
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Midnight UTC daily
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_threshold:
|
||||
description: 'Days of silence before pinging the author'
|
||||
required: false
|
||||
default: '4'
|
||||
dry_run:
|
||||
description: 'Log what would be pinged without taking action'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
concurrency:
|
||||
group: stale-issue-pr-ping
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install PyGithub==2.6.0
|
||||
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
@@ -127,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" />
|
||||
@@ -149,6 +149,7 @@
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
<PackageVersion Include="ReferenceTrimmer" Version="3.4.5" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-4
@@ -12,13 +12,9 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.Json" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.PowerFx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ public static class Program
|
||||
{
|
||||
Console.WriteLine($"{outputEvent}");
|
||||
}
|
||||
|
||||
if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
|
||||
Console.WriteLine($"Details: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
|
||||
/// <summary>
|
||||
/// A custom executor that uses an AI agent to provide feedback on a slogan.
|
||||
/// </summary>
|
||||
internal sealed class FeedbackExecutor : Executor<SloganResult>
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentSession? _session;
|
||||
|
||||
-1
@@ -14,7 +14,6 @@
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+42
@@ -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>
|
||||
+215
@@ -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);
|
||||
}
|
||||
}
|
||||
+52
@@ -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();
|
||||
+100
@@ -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`.
|
||||
+26
@@ -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}
|
||||
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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>"
|
||||
}
|
||||
}
|
||||
+42
@@ -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>
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
+90
@@ -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`.
|
||||
+14
@@ -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?
|
||||
+20
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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>"
|
||||
}
|
||||
}
|
||||
+43
@@ -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>
|
||||
+63
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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>"
|
||||
}
|
||||
}
|
||||
+29
@@ -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>
|
||||
+116
@@ -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);
|
||||
+93
@@ -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}");
|
||||
}
|
||||
}
|
||||
+83
@@ -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
|
||||
```
|
||||
|
||||
+30
@@ -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>
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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();
|
||||
+100
@@ -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
|
||||
```
|
||||
+29
@@ -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>
|
||||
+85
@@ -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.
|
||||
```
|
||||
+30
@@ -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>
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+133
@@ -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");
|
||||
}
|
||||
}
|
||||
+28
@@ -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>
|
||||
+129
@@ -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`.
|
||||
+29
@@ -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>
|
||||
+184
@@ -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}]";
|
||||
}
|
||||
}
|
||||
+117
@@ -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();
|
||||
+71
@@ -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).
|
||||
+28
@@ -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.
|
||||
+28
@@ -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 |
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="../Directory.Build.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ReferenceTrimmer" PrivateAssets="all" IncludeAssets="build;analyzers;buildTransitive" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -16,12 +16,9 @@
|
||||
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="System.Net.Http.Json" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,7 +2,36 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- 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))
|
||||
|
||||
@@ -12,15 +41,6 @@
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user