mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
86
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 | ||
|
|
1b7940c91e | ||
|
|
2f4c4aa614 | ||
|
|
052ba7be07 | ||
|
|
c67d3523ae | ||
|
|
83ce6a9602 | ||
|
|
50fdcbaf57 | ||
|
|
67b0282813 | ||
|
|
0009e330af | ||
|
|
a4b9539b62 | ||
|
|
b7990908fe | ||
|
|
84bae0f42a | ||
|
|
f696ac9b57 | ||
|
|
5e33deff45 | ||
|
|
b6a1315386 | ||
|
|
ed2fb3b9dd | ||
|
|
aa2ff672fb | ||
|
|
bcb55b4a98 | ||
|
|
921c5f9c17 | ||
|
|
fcdaaff9cd | ||
|
|
384291ba27 | ||
|
|
378bee577e | ||
|
|
18e433fc6d | ||
|
|
2f2495e196 | ||
|
|
e5d6e8ca98 | ||
|
|
b1866bd279 | ||
|
|
3e03a305f6 | ||
|
|
565c0b1623 | ||
|
|
53b0753dfb | ||
|
|
23ebfbc937 | ||
|
|
2f8fd5f82f | ||
|
|
60d5093421 | ||
|
|
d3f0c33180 | ||
|
|
97b6c9951a | ||
|
|
e35f530f2e | ||
|
|
a3bfad4791 | ||
|
|
09b3e2e4f0 | ||
|
|
55fc882ca8 | ||
|
|
c15f075412 | ||
|
|
fbcf1444ee | ||
|
|
1b7668119d | ||
|
|
fd1c66121e | ||
|
|
ded32f3ff8 | ||
|
|
e2f0bc814e | ||
|
|
6cb2289a16 | ||
|
|
2aaca50217 | ||
|
|
f74bda5a83 | ||
|
|
23d6d91c8f | ||
|
|
d5e240b375 | ||
|
|
1ca43f9643 | ||
|
|
b98880df32 | ||
|
|
c8750cbe92 |
@@ -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"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,10 @@ inputs:
|
||||
os:
|
||||
description: The operating system to set up
|
||||
required: true
|
||||
exclude-packages:
|
||||
description: Space-separated list of packages to exclude from uv sync
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@@ -19,6 +23,20 @@ runs:
|
||||
enable-cache: true
|
||||
cache-suffix: ${{ inputs.os }}-${{ inputs.python-version }}
|
||||
cache-dependency-glob: "**/uv.lock"
|
||||
- name: Exclude incompatible workspace packages
|
||||
if: ${{ inputs.exclude-packages != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
for pkg in ${{ inputs.exclude-packages }}; do
|
||||
for f in python/packages/*/pyproject.toml; do
|
||||
if grep -q "name = \"$pkg\"" "$f"; then
|
||||
pkg_dir=$(dirname "$f" | sed 's|python/||')
|
||||
echo "Excluding workspace package: $pkg ($pkg_dir)"
|
||||
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
|
||||
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
|
||||
fi
|
||||
done
|
||||
done
|
||||
- name: Install the project
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,11 +86,10 @@ jobs:
|
||||
run: docker pull mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }}
|
||||
|
||||
# This step will run dotnet format on each of the unique csproj files and fail if any changes are made
|
||||
# exclude-diagnostics should be removed after fixes for IL2026 and IL3050 are out: https://github.com/dotnet/sdk/issues/51136
|
||||
- name: Run dotnet format
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
run: |
|
||||
for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do
|
||||
echo "Running dotnet format on $csproj"
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic --exclude-diagnostics IL2026 IL3050"
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic"
|
||||
done
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -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:
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -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
|
||||
|
||||
@@ -117,7 +115,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
python-version: ["3.11"]
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Probe the highest allowed dependency versions, then open issues/PRs from the passing updates.
|
||||
name: Python - Dependency Range Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
dependency-range-validation:
|
||||
name: Dependency Range Validation
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# For now only run 3.13, if we do encounter situations where there are mismatches between packages and python versions (other then 3.10 and 3.14 which are known to not be able to install everything)
|
||||
# then we will have to reevaluate.
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Run dependency range validation
|
||||
id: validate_ranges
|
||||
# Keep workflow running so we can still publish diagnostics from this run.
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --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@v7
|
||||
with:
|
||||
name: dependency-range-results
|
||||
path: python/scripts/dependencies/dependency-range-results.json
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Create issues for failed dependency candidates
|
||||
# Always process the report so failed candidates create actionable tracking issues.
|
||||
if: always()
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require("fs")
|
||||
const reportPath = "python/scripts/dependencies/dependency-range-results.json"
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
core.warning(`No dependency range report found at ${reportPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"))
|
||||
const dependencyFailures = []
|
||||
|
||||
for (const packageResult of report.packages ?? []) {
|
||||
for (const dependency of packageResult.dependencies ?? []) {
|
||||
const candidateVersions = new Set(dependency.candidate_versions ?? [])
|
||||
const failedAttempts = (dependency.attempts ?? []).filter(
|
||||
(attempt) => attempt.status === "failed" && candidateVersions.has(attempt.trial_upper)
|
||||
)
|
||||
if (!failedAttempts.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const failuresByVersion = new Map()
|
||||
for (const attempt of failedAttempts) {
|
||||
const version = attempt.trial_upper || "unknown"
|
||||
if (!failuresByVersion.has(version)) {
|
||||
failuresByVersion.set(version, attempt.error || "No error output captured.")
|
||||
}
|
||||
}
|
||||
|
||||
dependencyFailures.push({
|
||||
packageName: packageResult.package_name,
|
||||
projectPath: packageResult.project_path,
|
||||
dependencyName: dependency.name,
|
||||
originalRequirements: dependency.original_requirements ?? [],
|
||||
finalRequirements: dependency.final_requirements ?? [],
|
||||
failedVersions: [...failuresByVersion.entries()].map(([version, error]) => ({ version, error })),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!dependencyFailures.length) {
|
||||
core.info("No failing dependency candidates found.")
|
||||
return
|
||||
}
|
||||
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const openIssues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
per_page: 100,
|
||||
})
|
||||
const openIssueTitles = new Set(
|
||||
openIssues.filter((issue) => !issue.pull_request).map((issue) => issue.title)
|
||||
)
|
||||
|
||||
const formatError = (message) => String(message || "No error output captured.").replace(/```/g, "'''")
|
||||
|
||||
for (const failure of dependencyFailures) {
|
||||
const title = `Dependency validation failed: ${failure.dependencyName} (${failure.packageName})`
|
||||
if (openIssueTitles.has(title)) {
|
||||
core.info(`Issue already exists: ${title}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const visibleFailures = failure.failedVersions.slice(0, 5)
|
||||
const omittedCount = failure.failedVersions.length - visibleFailures.length
|
||||
const failureDetails = visibleFailures
|
||||
.map(
|
||||
(entry) =>
|
||||
`- \`${entry.version}\`\n\n\`\`\`\n${formatError(entry.error).slice(0, 3500)}\n\`\`\``
|
||||
)
|
||||
.join("\n\n")
|
||||
|
||||
const body = [
|
||||
"Automated dependency range validation found candidate versions that failed checks.",
|
||||
"",
|
||||
`- Package: \`${failure.packageName}\``,
|
||||
`- Project path: \`${failure.projectPath}\``,
|
||||
`- Dependency: \`${failure.dependencyName}\``,
|
||||
`- Original requirements: ${
|
||||
failure.originalRequirements.length
|
||||
? failure.originalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
`- Final requirements after run: ${
|
||||
failure.finalRequirements.length
|
||||
? failure.finalRequirements.map((value) => `\`${value}\``).join(", ")
|
||||
: "_none_"
|
||||
}`,
|
||||
"",
|
||||
"### Failed versions and errors",
|
||||
failureDetails,
|
||||
omittedCount > 0 ? `\n_Additional failed versions omitted: ${omittedCount}_` : "",
|
||||
"",
|
||||
`Workflow run: ${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`,
|
||||
].join("\n")
|
||||
|
||||
await github.rest.issues.create({
|
||||
owner,
|
||||
repo,
|
||||
title,
|
||||
body,
|
||||
})
|
||||
openIssueTitles.add(title)
|
||||
core.info(`Created issue: ${title}`)
|
||||
}
|
||||
|
||||
- name: Refresh lockfile
|
||||
# Only refresh lockfile after a clean validation to avoid committing known-bad ranges.
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: uv lock --upgrade
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dependency updates
|
||||
id: commit_updates
|
||||
if: steps.validate_ranges.outcome == 'success'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "chore: update dependency ranges"
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
# Only open/update PRs for validated updates to keep automation branches trustworthy.
|
||||
if: steps.validate_ranges.outcome == 'success' && steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dependency-range-updates"
|
||||
PR_TITLE="Python: chore: update dependency ranges"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
This PR was generated by the dependency range validation workflow.
|
||||
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Python - Dev Dependency Upgrade
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
jobs:
|
||||
upgrade-dev-dependencies:
|
||||
name: Upgrade Dev Dependencies
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_PYTHON: "3.13"
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up python and install the project
|
||||
uses: ./.github/actions/python-setup
|
||||
with:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
|
||||
- name: Upgrade dev dependencies and validate workspace
|
||||
run: uv run poe upgrade-dev-dependencies
|
||||
working-directory: ./python
|
||||
|
||||
- name: Commit and push dev dependency updates
|
||||
id: commit_updates
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -B "${BRANCH}"
|
||||
|
||||
git add python/pyproject.toml python/packages/*/pyproject.toml python/uv.lock
|
||||
if git diff --cached --quiet; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No dev dependency updates to commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -F- <<'EOF'
|
||||
Python: chore: upgrade dev dependencies
|
||||
EOF
|
||||
git push --force-with-lease --set-upstream origin "${BRANCH}"
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update pull request with GitHub CLI
|
||||
if: steps.commit_updates.outputs.has_changes == 'true'
|
||||
run: |
|
||||
BRANCH="automation/python-dev-dependency-updates"
|
||||
PR_TITLE="Python: chore: upgrade dev dependencies"
|
||||
PR_BODY_FILE="$(mktemp)"
|
||||
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
### Motivation and Context
|
||||
|
||||
This automated update refreshes Python dev dependency pins across the workspace and reruns the repo validation gates before opening a pull request.
|
||||
|
||||
### Description
|
||||
|
||||
- Ran `uv run poe upgrade-dev-dependencies`
|
||||
- Refreshed dev dependency pins in workspace `pyproject.toml` files
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
- Reinstalled from the frozen lockfile and reran `check`, `typing`, and `test`
|
||||
|
||||
### Contribution Checklist
|
||||
|
||||
- [x] The code builds clean without any errors or warnings
|
||||
- [x] The PR follows the [Contribution Guidelines](https://github.com/microsoft/agent-framework/blob/main/CONTRIBUTING.md)
|
||||
- [x] All unit tests pass, and I have added new tests where possible
|
||||
- [ ] **Is this a breaking change?** If yes, add "[BREAKING]" prefix to the title of the PR.
|
||||
EOF
|
||||
|
||||
PR_NUMBER="$(gh pr list --head "${BRANCH}" --base main --state open --json number --jq '.[0].number')"
|
||||
if [ -n "${PR_NUMBER}" ]; then
|
||||
gh pr edit "${PR_NUMBER}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
else
|
||||
gh pr create --base main --head "${BRANCH}" --title "${PR_TITLE}" --body-file "${PR_BODY_FILE}"
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -170,7 +169,7 @@ jobs:
|
||||
environment: integration
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -67,6 +67,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
@@ -75,6 +76,9 @@ jobs:
|
||||
- name: Run lab tests
|
||||
run: cd packages/lab && uv run poe test
|
||||
|
||||
- name: Run resource-intensive lab tests
|
||||
run: cd packages/lab && uv run pytest -m "resource_intensive and not integration" --junitxml=test-results-resource-intensive.xml
|
||||
|
||||
- name: Run lab lint
|
||||
run: cd packages/lab && uv run poe lint
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -288,7 +287,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
OPENAI_CHAT_MODEL_ID: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OPENAI_RESPONSES_MODEL_ID: ${{ vars.OPENAI__RESPONSESMODELID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
run:
|
||||
working-directory: python
|
||||
env:
|
||||
UV_PYTHON: "3.10"
|
||||
UV_PYTHON: "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
# Save the PR number to a file since the workflow_run event
|
||||
@@ -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
|
||||
|
||||
@@ -34,12 +34,13 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
os: ${{ runner.os }}
|
||||
exclude-packages: ${{ matrix.python-version == '3.10' && 'agent-framework-github-copilot' || '' }}
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests
|
||||
run: uv run poe 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' }}
|
||||
@@ -205,6 +205,9 @@ WARP.md
|
||||
**/memory-bank/
|
||||
**/projectBrief.md
|
||||
**/tmpclaude*
|
||||
# Dependency-bound validation reports
|
||||
python/scripts/dependency-*-results.json
|
||||
python/scripts/dependencies/dependency-*-results.json
|
||||
|
||||
# Azurite storage emulator files
|
||||
*/__azurite_db_blob__.json*
|
||||
|
||||
@@ -4,8 +4,8 @@ status: accepted
|
||||
contact: westey-m
|
||||
date: 2025-07-10 {YYYY-MM-DD when the decision was last updated}
|
||||
deciders: sergeymenshykh, markwallace, rbarreto, dmytrostruk, westey-m, eavanvalkenburg, stephentoub
|
||||
consulted:
|
||||
informed:
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# Agent Run Responses Design
|
||||
@@ -64,7 +64,7 @@ Approaches observed from the compared SDKs:
|
||||
| AutoGen | **Approach 1** Separates messages into Agent-Agent (maps to Primary) and Internal (maps to Secondary) and these are returned as separate properties on the agent response object. See [types of messages](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/messages.html#types-of-messages) and [Response](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.Response) | **Approach 2** Returns a stream of internal events and the last item is a Response object. See [ChatAgent.on_messages_stream](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.ChatAgent.on_messages_stream) |
|
||||
| OpenAI Agent SDK | **Approach 1** Separates new_items (Primary+Secondary) from final output (Primary) as separate properties on the [RunResult](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L39) | **Approach 1** Similar to non-streaming, has a way of streaming updates via a method on the response object which includes all data, and then a separate final output property on the response object which is populated only when the run is complete. See [RunResultStreaming](https://github.com/openai/openai-agents-python/blob/main/src/agents/result.py#L136) |
|
||||
| Google ADK | **Approach 2** [Emits events](https://google.github.io/adk-docs/runtime/#step-by-step-breakdown) with [FinalResponse](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L232) true (Primary) / false (Secondary) and callers have to filter out those with false to get just the final response message | **Approach 2** Similar to non-streaming except [events](https://google.github.io/adk-docs/runtime/#streaming-vs-non-streaming-output-partialtrue) are emitted with [Partial](https://github.com/google/adk-java/blob/main/core/src/main/java/com/google/adk/events/Event.java#L133) true to indicate that they are streaming messages. A final non partial event is also emitted. |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.stream_async) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| AWS (Strands) | **Approach 3** Returns an [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) (Primary) with messages and a reason for the run's completion. | **Approach 2** [Streams events](https://strandsagents.com/docs/api/python/strands.agent.agent/) (Primary+Secondary) including, response text, current_tool_use, even data from "callbacks" (strands plugins) |
|
||||
| LangGraph | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) | **Approach 2** A mixed list of all [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | **Combination of various approaches** Returns a [RunResponse](https://docs.agno.com/reference/agents/run-response) object with text content, messages (essentially chat history including inputs and instructions), reasoning and thinking text properties. Secondary events could potentially be extracted from messages. | **Approach 2** Returns [RunResponseEvent](https://docs.agno.com/reference/agents/run-response#runresponseevent-types-and-attributes) objects including tool call, memory update, etc, information, where the [RunResponseCompletedEvent](https://docs.agno.com/reference/agents/run-response#runresponsecompletedevent) has similar properties to RunResponse|
|
||||
| A2A | **Approach 3** Returns a [Task or Message](https://a2aproject.github.io/A2A/latest/specification/#71-messagesend) where the message is the final result (Primary) and task is a reference to a long running process. | **Approach 2** Returns a [stream](https://a2aproject.github.io/A2A/latest/specification/#72-messagestream) that contains task updates (Secondary) and a final message (Primary) |
|
||||
@@ -496,7 +496,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | **Approach 1** Supports [configuring an agent](https://microsoft.github.io/autogen/stable/user-guide/agentchat-user-guide/tutorial/agents.html#structured-output) at agent creation. |
|
||||
| Google ADK | **Approach 1** Both [input and output schemas can be specified for LLM Agents](https://google.github.io/adk-docs/agents/llm-agents/#structuring-data-input_schema-output_schema-output_key) at construction time. This option is specific to this agent type and other agent types do not necessarily support |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent/#strands.agent.agent.Agent.structured_output) |
|
||||
| AWS (Strands) | **Approach 2** Supports a special invocation method called [structured_output](https://strandsagents.com/docs/api/python/strands.agent.agent/) |
|
||||
| LangGraph | **Approach 1** Supports [configuring an agent](https://langchain-ai.github.io/langgraph/agents/agents/?h=structured#6-configure-structured-output) at agent construction time, and a [structured response](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) can be retrieved as a special property on the agent response |
|
||||
| Agno | **Approach 1** Supports [configuring an agent](https://docs.agno.com/input-output/structured-output/agent) at agent construction time |
|
||||
| A2A | **Informal Approach 2** Doesn't formally support schema negotiation, but [hints can be provided via metadata](https://a2a-protocol.org/latest/specification/#97-structured-data-exchange-requesting-and-providing-json) at invocation time |
|
||||
@@ -508,7 +508,7 @@ We need to decide what AIContent types, each agent response type will be mapped
|
||||
|-|-|
|
||||
| AutoGen | Supports a [stop reason](https://microsoft.github.io/autogen/stable/reference/python/autogen_agentchat.base.html#autogen_agentchat.base.TaskResult.stop_reason) which is a freeform text string |
|
||||
| Google ADK | [No equivalent present](https://github.com/google/adk-python/blob/main/src/google/adk/events/event.py) |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/latest/documentation/docs/api-reference/python/types/event_loop/#strands.types.event_loop.StopReason) property on the [AgentResult](https://strandsagents.com/latest/documentation/docs/api-reference/python/agent/agent_result/) class with options that are tied closely to LLM operations. |
|
||||
| AWS (Strands) | Exposes a [stop_reason](https://strandsagents.com/docs/api/python/strands.types.event_loop/) property on the [AgentResult](https://strandsagents.com/docs/api/python/strands.agent.agent_result/) class with options that are tied closely to LLM operations. |
|
||||
| LangGraph | No equivalent present, output contains only [messages](https://langchain-ai.github.io/langgraph/agents/run_agents/#output-format) |
|
||||
| Agno | [No equivalent present](https://docs.agno.com/reference/agents/run-response) |
|
||||
| A2A | No equivalent present, response only contains a [message](https://a2a-protocol.org/latest/specification/#64-message-object) or [task](https://a2a-protocol.org/latest/specification/#61-task-object). |
|
||||
|
||||
@@ -1240,3 +1240,10 @@ class AttributionAwareStrategy(CompactionStrategy):
|
||||
|
||||
- [ADR-0016: Unifying Context Management with ContextPlugin](0016-python-context-middleware.md) — Parent ADR that established `ContextProvider`, `HistoryProvider`, and `AgentSession` architecture.
|
||||
- [Context Compaction Limitations Analysis](https://gist.github.com/victordibia/ec3f3baf97345f7e47da025cf55b999f) — Detailed analysis of why current architecture cannot support in-run compaction, with attempted solutions and their failure modes. Option 4 in this ADR corresponds to "Option A: Middleware Access to Mutable Message Source" from that analysis; Options 1-3 correspond to "Option B: Tool Loop Hook", adapted here to a `BaseChatClient` hook instead of `FunctionInvocationConfiguration`.
|
||||
|
||||
### Implementation Rollout Note
|
||||
|
||||
Implementation is split into two phases:
|
||||
|
||||
1. **Phase 1 (PR 1):** runtime compaction foundation in `agent_framework/_compaction.py`, in-run integration, and extensive core tests, plus in-run compaction samples (`basics`, `advanced`, `custom`).
|
||||
2. **Phase 2 (PR 2):** history/storage compaction (`upsert`-based full replacement), provider support, storage tests, and storage-focused sample (`storage`).
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.3.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.1" />
|
||||
<PackageVersion Include="Anthropic" Version="12.8.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.4.2" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
<PackageVersion Include="Aspire.Hosting.AppHost" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
|
||||
@@ -33,14 +33,15 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.4" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.4" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.9.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.4" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.3" />
|
||||
@@ -101,13 +102,14 @@
|
||||
<PackageVersion Include="Microsoft.Agents.Authentication.Msal" Version="1.3.171-beta" />
|
||||
<PackageVersion Include="Microsoft.Agents.Hosting.AspNetCore" Version="1.3.171-beta" />
|
||||
<!-- A2A -->
|
||||
<PackageVersion Include="A2A" Version="0.3.3-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.3-preview" />
|
||||
<PackageVersion Include="A2A" Version="0.3.4-preview" />
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="0.3.4-preview" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="0.8.0-preview.1" />
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="AWSSDK.Extensions.Bedrock.MEAI" Version="4.0.5.1" />
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.8.0" />
|
||||
<!-- Identity -->
|
||||
@@ -125,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" />
|
||||
@@ -147,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" />
|
||||
|
||||
@@ -56,10 +56,30 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step15_DeepResearch/Agent_Step15_DeepResearch.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step16_Declarative/Agent_Step16_Declarative.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableWorkflows/" />
|
||||
<Folder Name="/Samples/04-hosting/DurableWorkflows/ConsoleApps/">
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/DurableWorkflows/AzureFunctions/">
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
|
||||
<Project Path="samples/04-hosting/DurableWorkflows/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/GettingStarted/">
|
||||
<File Path="samples/GettingStarted/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/">
|
||||
<File Path="samples/02-agents/AGUI/README.md" />
|
||||
</Folder>
|
||||
@@ -103,6 +123,7 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step02_MemoryUsingMem0/AgentWithMemory_Step02_MemoryUsingMem0.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithOpenAI/">
|
||||
<File Path="samples/02-agents/AgentWithOpenAI/README.md" />
|
||||
@@ -518,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>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"src\\Microsoft.Agents.AI.Declarative\\Microsoft.Agents.AI.Declarative.csproj",
|
||||
"src\\Microsoft.Agents.AI.DevUI\\Microsoft.Agents.AI.DevUI.csproj",
|
||||
"src\\Microsoft.Agents.AI.DurableTask\\Microsoft.Agents.AI.DurableTask.csproj",
|
||||
"src\\Microsoft.Agents.AI.FoundryMemory\\Microsoft.Agents.AI.FoundryMemory.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore\\Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.A2A\\Microsoft.Agents.AI.Hosting.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"version": "10.0.200",
|
||||
"rollForward": "minor",
|
||||
"allowPrerelease": false
|
||||
},
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.0.0</VersionPrefix>
|
||||
<RCNumber>3</RCNumber>
|
||||
<RCNumber>4</RCNumber>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260304.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260304.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc3</GitTag>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).260311.1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.260311.1</PackageVersion>
|
||||
<GitTag>1.0.0-rc4</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -82,7 +82,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant with access to restaurant information.",
|
||||
tools: tools);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -27,7 +26,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent agent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -60,7 +60,7 @@ ChatClient openAIChatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
ChatClientAgent baseAgent = openAIChatClient.AsIChatClient().AsAIAgent(
|
||||
ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
|
||||
name: "AGUIAssistant",
|
||||
instructions: "You are a helpful assistant in charge of approving expenses",
|
||||
tools: tools);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -4,7 +4,6 @@ using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hosting.AGUI.AspNetCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OpenAI.Chat;
|
||||
using RecipeAssistant;
|
||||
@@ -37,7 +36,7 @@ ChatClient chatClient = new AzureOpenAIClient(
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent baseAgent = chatClient.AsIChatClient().AsAIAgent(
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
name: "RecipeAgent",
|
||||
instructions: """
|
||||
You are a helpful recipe assistant. When users ask you to create or suggest a recipe,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+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>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="ChatHistoryProvider"/> that keeps a bounded window of recent messages in session state
|
||||
/// (via <see cref="InMemoryChatHistoryProvider"/>) and overflows older messages to a vector store
|
||||
/// (via <see cref="ChatHistoryMemoryProvider"/>). When providing chat history, it searches the vector
|
||||
/// store for relevant older messages and prepends them as a memory context message.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only non-system messages are counted towards the session state limit and overflow mechanism. System messages are always retained in session state and are not included in the vector store.
|
||||
/// Function calls and function results are also dropped when truncation happens, both from in-memory state, and they are also not persisted to the vector store.
|
||||
/// </remarks>
|
||||
internal sealed class BoundedChatHistoryProvider : ChatHistoryProvider, IDisposable
|
||||
{
|
||||
private readonly InMemoryChatHistoryProvider _chatHistoryProvider;
|
||||
private readonly ChatHistoryMemoryProvider _memoryProvider;
|
||||
private readonly TruncatingChatReducer _reducer;
|
||||
private readonly string _contextPrompt;
|
||||
private IReadOnlyList<string>? _stateKeys;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BoundedChatHistoryProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxSessionMessages">The maximum number of non-system messages to keep in session state before overflowing to the vector store.</param>
|
||||
/// <param name="vectorStore">The vector store to use for storing and retrieving overflow chat history.</param>
|
||||
/// <param name="collectionName">The name of the collection for storing overflow chat history in the vector store.</param>
|
||||
/// <param name="vectorDimensions">The number of dimensions to use for the chat history vector store embeddings.</param>
|
||||
/// <param name="stateInitializer">A delegate that initializes the memory provider state, providing the storage and search scopes.</param>
|
||||
/// <param name="contextPrompt">Optional prompt to prefix memory search results. Defaults to a standard memory context prompt.</param>
|
||||
public BoundedChatHistoryProvider(
|
||||
int maxSessionMessages,
|
||||
VectorStore vectorStore,
|
||||
string collectionName,
|
||||
int vectorDimensions,
|
||||
Func<AgentSession?, ChatHistoryMemoryProvider.State> stateInitializer,
|
||||
string? contextPrompt = null)
|
||||
{
|
||||
if (maxSessionMessages < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSessionMessages), "maxSessionMessages must be non-negative.");
|
||||
}
|
||||
|
||||
this._reducer = new TruncatingChatReducer(maxSessionMessages);
|
||||
this._chatHistoryProvider = new InMemoryChatHistoryProvider(new InMemoryChatHistoryProviderOptions
|
||||
{
|
||||
ChatReducer = this._reducer,
|
||||
ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._memoryProvider = new ChatHistoryMemoryProvider(
|
||||
vectorStore,
|
||||
collectionName,
|
||||
vectorDimensions,
|
||||
stateInitializer,
|
||||
options: new ChatHistoryMemoryProviderOptions
|
||||
{
|
||||
SearchInputMessageFilter = msgs => msgs,
|
||||
StorageInputRequestMessageFilter = msgs => msgs,
|
||||
});
|
||||
this._contextPrompt = contextPrompt
|
||||
?? "The following are memories from earlier in this conversation. Use them to inform your responses:";
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => this._stateKeys ??= this._chatHistoryProvider.StateKeys.Concat(this._memoryProvider.StateKeys).ToArray();
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<IEnumerable<ChatMessage>> ProvideChatHistoryAsync(
|
||||
InvokingContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate to the inner provider's full lifecycle (retrieve, filter, stamp, merge with request messages).
|
||||
var chatHistoryProviderInputContext = new InvokingContext(context.Agent, context.Session, []);
|
||||
var allMessages = await this._chatHistoryProvider.InvokingAsync(chatHistoryProviderInputContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Search the vector store for relevant older messages.
|
||||
var aiContext = new AIContext { Messages = context.RequestMessages.ToList() };
|
||||
var invokingContext = new AIContextProvider.InvokingContext(
|
||||
context.Agent, context.Session, aiContext);
|
||||
|
||||
var result = await this._memoryProvider.InvokingAsync(invokingContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Extract only the messages added by the memory provider (stamped with AIContextProvider source type).
|
||||
var memoryMessages = result.Messages?
|
||||
.Where(m => m.GetAgentRequestMessageSourceType() == AgentRequestMessageSourceType.AIContextProvider)
|
||||
.ToList();
|
||||
|
||||
if (memoryMessages is { Count: > 0 })
|
||||
{
|
||||
var memoryText = string.Join("\n", memoryMessages.Select(m => m.Text).Where(t => !string.IsNullOrWhiteSpace(t)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(memoryText))
|
||||
{
|
||||
var contextMessage = new ChatMessage(ChatRole.User, $"{this._contextPrompt}\n{memoryText}");
|
||||
return new[] { contextMessage }.Concat(allMessages);
|
||||
}
|
||||
}
|
||||
|
||||
return allMessages;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask StoreChatHistoryAsync(
|
||||
InvokedContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Delegate storage to the in-memory provider. Its TruncatingChatReducer (AfterMessageAdded trigger)
|
||||
// will automatically truncate to the configured maximum and expose any removed messages.
|
||||
var innerContext = new InvokedContext(
|
||||
context.Agent, context.Session, context.RequestMessages, context.ResponseMessages!);
|
||||
await this._chatHistoryProvider.InvokedAsync(innerContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Archive any messages that the reducer removed to the vector store.
|
||||
if (this._reducer.RemovedMessages is { Count: > 0 })
|
||||
{
|
||||
var overflowContext = new AIContextProvider.InvokedContext(
|
||||
context.Agent, context.Session, this._reducer.RemovedMessages, []);
|
||||
await this._memoryProvider.InvokedAsync(overflowContext, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
this._memoryProvider.Dispose();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to create a bounded chat history provider that keeps a configurable number of
|
||||
// recent messages in session state and automatically overflows older messages to a vector store.
|
||||
// When the agent is invoked, it searches the vector store for relevant older messages and
|
||||
// prepends them as a "memory" context message before the recent session history.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.VectorData;
|
||||
using Microsoft.SemanticKernel.Connectors.InMemory;
|
||||
using OpenAI.Chat;
|
||||
using SampleApp;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
var embeddingDeploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-3-large";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
var credential = new DefaultAzureCredential();
|
||||
|
||||
// Create a vector store to store overflow chat messages.
|
||||
// For demonstration purposes, we are using an in-memory vector store.
|
||||
// Replace this with a persistent vector store implementation for production scenarios.
|
||||
VectorStore vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions()
|
||||
{
|
||||
EmbeddingGenerator = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetEmbeddingClient(embeddingDeploymentName)
|
||||
.AsIEmbeddingGenerator()
|
||||
});
|
||||
|
||||
var sessionId = Guid.NewGuid().ToString();
|
||||
|
||||
// Create the BoundedChatHistoryProvider with a maximum of 4 non-system messages in session state.
|
||||
// It internally creates an InMemoryChatHistoryProvider with a TruncatingChatReducer and a
|
||||
// ChatHistoryMemoryProvider with the correct configuration to ensure overflow messages are
|
||||
// automatically archived to the vector store and recalled via semantic search.
|
||||
var boundedProvider = new BoundedChatHistoryProvider(
|
||||
maxSessionMessages: 4,
|
||||
vectorStore,
|
||||
collectionName: "chathistory-overflow",
|
||||
vectorDimensions: 3072,
|
||||
session => new ChatHistoryMemoryProvider.State(
|
||||
storageScope: new() { UserId = "UID1", SessionId = sessionId },
|
||||
searchScope: new() { UserId = "UID1" }));
|
||||
|
||||
// Create the agent with the bounded chat history provider.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), credential)
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Answer questions concisely." },
|
||||
Name = "Assistant",
|
||||
ChatHistoryProvider = boundedProvider,
|
||||
});
|
||||
|
||||
// Start a conversation. The first several exchanges will fill up the session state window.
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("--- Filling the session window (4 messages max) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("My favorite color is blue.", session));
|
||||
Console.WriteLine(await agent.RunAsync("I have a dog named Max.", session));
|
||||
|
||||
// At this point the session state holds 4 messages (2 user + 2 assistant).
|
||||
// The next exchange will push the oldest messages into the vector store.
|
||||
Console.WriteLine("\n--- Next exchange will trigger overflow to vector store ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the capital of France?", session));
|
||||
|
||||
// The oldest messages about favorite color have now been archived to the vector store.
|
||||
// Ask the agent something that requires recalling the overflowed information.
|
||||
Console.WriteLine("\n--- Asking about overflowed information (should recall from vector store) ---\n");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is my favorite color?", session));
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Bounded Chat History with Vector Store Overflow
|
||||
|
||||
This sample demonstrates how to create a custom `ChatHistoryProvider` that keeps a bounded window of recent messages in session state and automatically overflows older messages to a vector store. When the agent is invoked, it searches the vector store for relevant older messages and prepends them as memory context.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`TruncatingChatReducer`**: A custom `IChatReducer` that keeps the most recent N messages and exposes removed messages via a `RemovedMessages` property.
|
||||
- **`BoundedChatHistoryProvider`**: A custom `ChatHistoryProvider` that composes:
|
||||
- `InMemoryChatHistoryProvider` for fast session-state storage (bounded by the reducer)
|
||||
- `ChatHistoryMemoryProvider` for vector-store overflow and semantic search of older messages
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An Azure OpenAI resource with:
|
||||
- A chat deployment (e.g., `gpt-4o-mini`)
|
||||
- An embedding deployment (e.g., `text-embedding-3-large`)
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Your Azure OpenAI endpoint URL | *(required)* |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Chat model deployment name | `gpt-4o-mini` |
|
||||
| `AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME` | Embedding model deployment name | `text-embedding-3-large` |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
1. The agent starts a conversation with a bounded session window of 4 non-system, non-function messages (i.e., user/assistant turns). System messages are always preserved, and function call/result messages are truncated and not preserved.
|
||||
2. As messages accumulate beyond the limit, the `TruncatingChatReducer` removes the oldest messages.
|
||||
3. The `BoundedChatHistoryProvider` detects the removed messages and stores them in a vector store via `ChatHistoryMemoryProvider`.
|
||||
4. On subsequent invocations, the provider searches the vector store for relevant older messages and prepends them as memory context, allowing the agent to recall information from earlier in the conversation.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace SampleApp;
|
||||
|
||||
/// <summary>
|
||||
/// A truncating chat reducer that keeps the most recent messages up to a configured maximum,
|
||||
/// preserving any leading system message. Removed messages are exposed via <see cref="RemovedMessages"/>
|
||||
/// so that a caller can archive them (e.g. to a vector store).
|
||||
/// </summary>
|
||||
internal sealed class TruncatingChatReducer : IChatReducer
|
||||
{
|
||||
private readonly int _maxMessages;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TruncatingChatReducer"/> class.
|
||||
/// </summary>
|
||||
/// <param name="maxMessages">The maximum number of non-system messages to retain.</param>
|
||||
public TruncatingChatReducer(int maxMessages)
|
||||
{
|
||||
this._maxMessages = maxMessages > 0 ? maxMessages : throw new ArgumentOutOfRangeException(nameof(maxMessages));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the messages that were removed during the most recent call to <see cref="ReduceAsync"/>.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ChatMessage> RemovedMessages { get; private set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = messages ?? throw new ArgumentNullException(nameof(messages));
|
||||
|
||||
ChatMessage? systemMessage = null;
|
||||
Queue<ChatMessage> retained = new(capacity: this._maxMessages);
|
||||
List<ChatMessage> removed = [];
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.Role == ChatRole.System)
|
||||
{
|
||||
// Preserve the first system message outside the counting window.
|
||||
systemMessage ??= message;
|
||||
}
|
||||
else if (!message.Contents.Any(c => c is FunctionCallContent or FunctionResultContent))
|
||||
{
|
||||
if (retained.Count >= this._maxMessages)
|
||||
{
|
||||
removed.Add(retained.Dequeue());
|
||||
}
|
||||
|
||||
retained.Enqueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
this.RemovedMessages = removed;
|
||||
|
||||
IEnumerable<ChatMessage> result = systemMessage is not null
|
||||
? new[] { systemMessage }.Concat(retained)
|
||||
: retained;
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
@@ -8,5 +8,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Memory with MemoryStore](./AgentWithMemory_Step02_MemoryUsingMem0/)|This sample demonstrates how to create and run an agent that uses the Mem0 service to extract and retrieve individual memories.|
|
||||
|[Custom Memory Implementation](../../01-get-started/04_memory/)|This sample demonstrates how to create a custom memory component and attach it to an agent.|
|
||||
|[Memory with Azure AI Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Azure AI Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../FoundryAgents/FoundryAgents_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Azure Foundry Agents.
|
||||
|
||||
-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>
|
||||
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,120 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to use a CompactionProvider with a compaction pipeline
|
||||
// as an AIContextProvider for an agent's in-run context management. The pipeline chains multiple
|
||||
// compaction strategies from gentle to aggressive:
|
||||
// 1. ToolResultCompactionStrategy - Collapses old tool-call groups into concise summaries
|
||||
// 2. SummarizationCompactionStrategy - LLM-compresses older conversation spans
|
||||
// 3. SlidingWindowCompactionStrategy - Keeps only the most recent N user turns
|
||||
// 4. TruncationCompactionStrategy - Emergency token-budget backstop
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AzureOpenAIClient openAIClient = new(new Uri(endpoint), new DefaultAzureCredential());
|
||||
|
||||
// Create a chat client for the agent and a separate one for the summarization strategy.
|
||||
// Using the same model for simplicity; in production, use a smaller/cheaper model for summarization.
|
||||
IChatClient agentChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient(deploymentName).AsIChatClient();
|
||||
|
||||
// Define a tool the agent can use, so we can see tool-result compaction in action.
|
||||
[Description("Look up the current price of a product by name.")]
|
||||
static string LookupPrice([Description("The product name to look up.")] string productName) =>
|
||||
productName.ToUpperInvariant() switch
|
||||
{
|
||||
"LAPTOP" => "The laptop costs $999.99.",
|
||||
"KEYBOARD" => "The keyboard costs $79.99.",
|
||||
"MOUSE" => "The mouse costs $29.99.",
|
||||
_ => $"Sorry, I don't have pricing for '{productName}'."
|
||||
};
|
||||
|
||||
// Configure the compaction pipeline with one of each strategy, ordered least to most aggressive.
|
||||
PipelineCompactionStrategy compactionPipeline =
|
||||
new(// 1. Gentle: collapse old tool-call groups into short summaries
|
||||
new ToolResultCompactionStrategy(CompactionTriggers.MessagesExceed(7)),
|
||||
|
||||
// 2. Moderate: use an LLM to summarize older conversation spans into a concise message
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(0x500)),
|
||||
|
||||
// 3. Aggressive: keep only the last N user turns and their responses
|
||||
new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(4)),
|
||||
|
||||
// 4. Emergency: drop oldest groups until under the token budget
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(0x8000)));
|
||||
|
||||
// Create the agent with a CompactionProvider that uses the compaction pipeline.
|
||||
AIAgent agent =
|
||||
agentChatClient
|
||||
.AsBuilder()
|
||||
// Note: Adding the CompactionProvider at the builder level means it will be applied to all agents
|
||||
// built from this builder and will manage context for both agent messages and tool calls.
|
||||
.UseAIContextProviders(new CompactionProvider(compactionPipeline))
|
||||
.BuildAIAgent(
|
||||
new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ShoppingAssistant",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are a helpful, but long winded, shopping assistant.
|
||||
Help the user look up prices and compare products.
|
||||
When responding, Be sure to be extra descriptive and use as
|
||||
many words as possible without sounding ridiculous.
|
||||
""",
|
||||
Tools = [AIFunctionFactory.Create(LookupPrice)]
|
||||
},
|
||||
// Note: AIContextProviders may be specified here instead of ChatClientBuilder.UseAIContextProviders.
|
||||
// Specifying compaction at the agent level skips compaction in the function calling loop.
|
||||
//AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Helper to print chat history size
|
||||
void PrintChatHistory()
|
||||
{
|
||||
if (session.TryGetInMemoryChatHistory(out var history))
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"\n[Messages: #{history.Count}]\n");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
// Run a multi-turn conversation with tool calls to exercise the pipeline.
|
||||
string[] prompts =
|
||||
[
|
||||
"What's the price of a laptop?",
|
||||
"How about a keyboard?",
|
||||
"And a mouse?",
|
||||
"Which product is the cheapest?",
|
||||
"Can you compare the laptop and the keyboard for me?",
|
||||
"What was the first product I asked about?",
|
||||
"Thank you!",
|
||||
];
|
||||
|
||||
foreach (string prompt in prompts)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[User] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(prompt);
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.Write("\n[Agent] ");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(await agent.RunAsync(prompt, session));
|
||||
|
||||
PrintChatHistory();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
# Compaction Pipeline
|
||||
|
||||
This sample demonstrates how to use a `CompactionProvider` with a `PipelineCompactionStrategy` to manage long conversation histories in a token-efficient way. The pipeline chains four compaction strategies, ordered from gentle to aggressive, so that the least disruptive strategy runs first and more aggressive strategies only activate when necessary.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- **`CompactionProvider`** — an `AIContextProvider` that applies a compaction strategy before each agent invocation, keeping only the most relevant messages within the model's context window
|
||||
- **`PipelineCompactionStrategy`** — chains multiple compaction strategies into an ordered pipeline; each strategy evaluates its own trigger independently and operates on the output of the previous one
|
||||
- **`ToolResultCompactionStrategy`** — collapses older tool-call groups into concise inline summaries, activated by a message-count trigger
|
||||
- **`SummarizationCompactionStrategy`** — uses an LLM to compress older conversation spans into a single summary message, activated by a token-count trigger
|
||||
- **`SlidingWindowCompactionStrategy`** — retains only the most recent N user turns and their responses, activated by a turn-count trigger
|
||||
- **`TruncationCompactionStrategy`** — emergency backstop that drops the oldest groups until the conversation fits within a hard token budget
|
||||
- **`CompactionTriggers`** — factory methods (`MessagesExceed`, `TokensExceed`, `TurnsExceed`, `GroupsExceed`, `HasToolCalls`, `All`, `Any`) that control when each strategy activates
|
||||
|
||||
## Concepts
|
||||
|
||||
### Message groups
|
||||
|
||||
The compaction engine organizes messages into atomic *groups* that are treated as indivisible units during compaction. A group is either:
|
||||
|
||||
| Group kind | Contents |
|
||||
|---|---|
|
||||
| `System` | System prompt message(s) |
|
||||
| `User` | A single user message |
|
||||
| `ToolCall` | One assistant message with tool calls + the matching tool result messages |
|
||||
| `AssistantText` | A single assistant text-only message |
|
||||
| `Summary` | One or more messages summarizing earlier conversation spans, produced by compaction strategies |
|
||||
|
||||
`Summary` groups (`CompactionGroupKind.Summary`) are created by compaction strategies (for example, `SummarizationCompactionStrategy`) and do not originate directly from user or assistant messages.
|
||||
Strategies exclude entire groups rather than individual messages, preserving the tool-call/result pairing required by most model APIs.
|
||||
|
||||
### Compaction triggers
|
||||
|
||||
A `CompactionTrigger` is a predicate evaluated against the current `MessageIndex`. When the trigger fires, the strategy performs compaction; when it does not fire, the strategy is skipped. Available triggers are:
|
||||
|
||||
| Trigger | Activates when… |
|
||||
|---|---|
|
||||
| `CompactionTriggers.Always` | Always (unconditional) |
|
||||
| `CompactionTriggers.Never` | Never (disabled) |
|
||||
| `CompactionTriggers.MessagesExceed(n)` | Included message count > n |
|
||||
| `CompactionTriggers.TokensExceed(n)` | Included token count > n |
|
||||
| `CompactionTriggers.TurnsExceed(n)` | Included user-turn count > n |
|
||||
| `CompactionTriggers.GroupsExceed(n)` | Included group count > n |
|
||||
| `CompactionTriggers.HasToolCalls()` | At least one included tool-call group exists |
|
||||
| `CompactionTriggers.All(...)` | All supplied triggers fire (logical AND) |
|
||||
| `CompactionTriggers.Any(...)` | Any supplied trigger fires (logical OR) |
|
||||
|
||||
### Pipeline ordering
|
||||
|
||||
Order strategies from **least aggressive** to **most aggressive**. The pipeline runs every strategy whose trigger is met. Earlier strategies reduce the conversation gently so that later, more destructive strategies may not need to activate at all.
|
||||
|
||||
```
|
||||
1. ToolResultCompactionStrategy – gentle: replaces verbose tool results with a short label
|
||||
2. SummarizationCompactionStrategy – moderate: LLM-summarizes older turns
|
||||
3. SlidingWindowCompactionStrategy – aggressive: drops turns beyond the window
|
||||
4. TruncationCompactionStrategy – emergency: hard token-budget enforcement
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Azure OpenAI service endpoint and model deployment
|
||||
- Azure CLI installed and authenticated
|
||||
|
||||
**Note**: This sample uses `DefaultAzureCredential`. Sign in with `az login` before running. For production, prefer a specific credential such as `ManagedIdentityCredential`. For more information, see the [Azure CLI authentication documentation](https://learn.microsoft.com/cli/azure/authenticate-azure-cli-interactively).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/" # Required
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4o-mini" # Optional, defaults to gpt-4o-mini
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/02-agents/Agents/Agent_Step18_CompactionPipeline
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
The sample runs a seven-turn shopping-assistant conversation with tool calls. After each turn it prints the full message count so you can observe the pipeline compaction doesn't alter the source conversation.
|
||||
|
||||
Each of the four compaction strategies has a deliberately low threshold so that it activates during the short demonstration conversation. In a production scenario you would raise the thresholds to match your model's context window and cost requirements.
|
||||
|
||||
## Customizing the Pipeline
|
||||
|
||||
### Using a single strategy
|
||||
|
||||
If you only need one compaction strategy, pass it directly to `CompactionProvider` without wrapping it in a pipeline:
|
||||
|
||||
```csharp
|
||||
CompactionProvider provider =
|
||||
new(new SlidingWindowCompactionStrategy(CompactionTriggers.TurnsExceed(20)));
|
||||
```
|
||||
|
||||
### Ad-hoc compaction outside the provider pipeline
|
||||
|
||||
`CompactionProvider.CompactAsync` applies a strategy to an arbitrary list of messages without an active agent session:
|
||||
|
||||
```csharp
|
||||
IEnumerable<ChatMessage> compacted = await CompactionProvider.CompactAsync(
|
||||
new TruncationCompactionStrategy(CompactionTriggers.TokensExceed(8000)),
|
||||
existingMessages);
|
||||
```
|
||||
|
||||
### Using a different model for summarization
|
||||
|
||||
The `SummarizationCompactionStrategy` accepts any `IChatClient`. Use a smaller, cheaper model to reduce summarization cost:
|
||||
|
||||
```csharp
|
||||
IChatClient summarizerChatClient = openAIClient.GetChatClient("gpt-4o-mini").AsIChatClient();
|
||||
new SummarizationCompactionStrategy(summarizerChatClient, CompactionTriggers.TokensExceed(4000))
|
||||
```
|
||||
|
||||
### Registering through `ChatClientAgentOptions`
|
||||
|
||||
`CompactionProvider` can also be specified directly on `ChatClientAgentOptions` instead of calling `UseAIContextProviders` on the `ChatClientBuilder`:
|
||||
|
||||
```csharp
|
||||
AIAgent agent = agentChatClient
|
||||
.AsBuilder()
|
||||
.BuildAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [new CompactionProvider(compactionPipeline)]
|
||||
});
|
||||
```
|
||||
|
||||
This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client.
|
||||
|
||||
> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`.
|
||||
@@ -44,6 +44,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Deep research with an agent](./Agent_Step15_DeepResearch/)|This sample demonstrates how to use the Deep Research Tool to perform comprehensive research on complex topics|
|
||||
|[Declarative agent](./Agent_Step16_Declarative/)|This sample demonstrates how to declaratively define an agent.|
|
||||
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+72
-56
@@ -12,11 +12,8 @@ using OpenAI.Responses;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini";
|
||||
|
||||
// Memory store configuration
|
||||
// NOTE: Memory stores must be created beforehand via Azure Portal or Python SDK.
|
||||
// The .NET SDK currently only supports using existing memory stores with agents.
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? throw new InvalidOperationException("AZURE_AI_MEMORY_STORE_ID is not set.");
|
||||
string embeddingModelName = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
string memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? $"foundry-memory-sample-{Guid.NewGuid():N}";
|
||||
|
||||
const string AgentInstructions = """
|
||||
You are a helpful assistant that remembers past conversations.
|
||||
@@ -32,71 +29,57 @@ const string AgentNameNative = "MemorySearchAgent-NATIVE";
|
||||
string userScope = $"user_{Environment.MachineName}";
|
||||
|
||||
// Get a client to create/retrieve/delete server side agents with Azure Foundry Agents.
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), new AzureCliCredential());
|
||||
DefaultAzureCredential credential = new();
|
||||
AIProjectClient aiProjectClient = new(new Uri(endpoint), credential);
|
||||
|
||||
// Ensure the memory store exists and has memories to retrieve.
|
||||
await EnsureMemoryStoreAsync();
|
||||
|
||||
// Create the Memory Search tool configuration
|
||||
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope)
|
||||
{
|
||||
// Optional: Configure how quickly new memories are indexed (in seconds)
|
||||
UpdateDelay = 1,
|
||||
|
||||
// Optional: Configure search behavior
|
||||
SearchOptions = new MemorySearchToolOptions
|
||||
{
|
||||
// Additional search options can be configured here if needed
|
||||
}
|
||||
};
|
||||
MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelay = 0 };
|
||||
|
||||
// Create agent using Option 1 (MEAI) or Option 2 (Native SDK)
|
||||
AIAgent agent = await CreateAgentWithMEAI();
|
||||
// AIAgent agent = await CreateAgentWithNativeSDK();
|
||||
|
||||
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
|
||||
|
||||
// Conversation 1: Share some personal information
|
||||
Console.WriteLine("User: My name is Alice and I love programming in C#.");
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice and I love programming in C#.");
|
||||
Console.WriteLine($"Agent: {response1.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Allow time for memory to be indexed
|
||||
await Task.Delay(2000);
|
||||
|
||||
// Conversation 2: Test if the agent remembers
|
||||
Console.WriteLine("User: What's my name and what programming language do I prefer?");
|
||||
AgentResponse response2 = await agent.RunAsync("What's my name and what programming language do I prefer?");
|
||||
Console.WriteLine($"Agent: {response2.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Inspect memory search results if available in raw response items
|
||||
// Note: Memory search tool call results appear as AgentResponseItem types
|
||||
foreach (var message in response2.Messages)
|
||||
try
|
||||
{
|
||||
if (message.RawRepresentation is AgentResponseItem agentResponseItem &&
|
||||
agentResponseItem is MemorySearchToolCallResponseItem memorySearchResult)
|
||||
{
|
||||
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
|
||||
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
|
||||
Console.WriteLine("Agent created with Memory Search tool. Starting conversation...\n");
|
||||
|
||||
foreach (var result in memorySearchResult.Results)
|
||||
// The agent uses the memory search tool to recall stored information.
|
||||
Console.WriteLine("User: What's my name and what programming language do I prefer?");
|
||||
AgentResponse response = await agent.RunAsync("What's my name and what programming language do I prefer?");
|
||||
Console.WriteLine($"Agent: {response.Messages.LastOrDefault()?.Text}\n");
|
||||
|
||||
// Inspect memory search results if available in raw response items.
|
||||
foreach (var message in response.Messages)
|
||||
{
|
||||
if (message.RawRepresentation is MemorySearchToolCallResponseItem memorySearchResult)
|
||||
{
|
||||
var memoryItem = result.MemoryItem;
|
||||
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
|
||||
Console.WriteLine($" Scope: {memoryItem.Scope}");
|
||||
Console.WriteLine($" Content: {memoryItem.Content}");
|
||||
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
|
||||
Console.WriteLine($"Memory Search Status: {memorySearchResult.Status}");
|
||||
Console.WriteLine($"Memory Search Results Count: {memorySearchResult.Results.Count}");
|
||||
|
||||
foreach (var result in memorySearchResult.Results)
|
||||
{
|
||||
var memoryItem = result.MemoryItem;
|
||||
Console.WriteLine($" - Memory ID: {memoryItem.MemoryId}");
|
||||
Console.WriteLine($" Scope: {memoryItem.Scope}");
|
||||
Console.WriteLine($" Content: {memoryItem.Content}");
|
||||
Console.WriteLine($" Updated: {memoryItem.UpdatedAt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Cleanup: Delete the agent and memory store.
|
||||
Console.WriteLine("\nCleaning up...");
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine("Agent deleted.");
|
||||
await aiProjectClient.MemoryStores.DeleteMemoryStoreAsync(memoryStoreName);
|
||||
Console.WriteLine("Memory store deleted.");
|
||||
}
|
||||
|
||||
// Cleanup: Delete the agent (memory store persists and should be cleaned up separately if needed)
|
||||
Console.WriteLine("\nCleaning up agent...");
|
||||
await aiProjectClient.Agents.DeleteAgentAsync(agent.Name);
|
||||
Console.WriteLine("Agent deleted successfully.");
|
||||
|
||||
// NOTE: Memory stores are long-lived resources and are NOT deleted with the agent.
|
||||
// To delete a memory store, use the Azure Portal or Python SDK:
|
||||
// await project_client.memory_stores.delete(memory_store.name)
|
||||
|
||||
// --- Agent Creation Options ---
|
||||
#pragma warning disable CS8321 // Local function is declared but never used
|
||||
|
||||
// Option 1 - Using MemorySearchTool wrapped as MEAI AITool
|
||||
@@ -122,3 +105,36 @@ async Task<AIAgent> CreateAgentWithNativeSDK()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Helpers — kept at the bottom so the main agent flow above stays clean.
|
||||
async Task EnsureMemoryStoreAsync()
|
||||
{
|
||||
Console.WriteLine($"Creating memory store '{memoryStoreName}'...");
|
||||
try
|
||||
{
|
||||
await aiProjectClient.MemoryStores.GetMemoryStoreAsync(memoryStoreName);
|
||||
Console.WriteLine("Memory store already exists.");
|
||||
}
|
||||
catch (System.ClientModel.ClientResultException ex) when (ex.Status == 404)
|
||||
{
|
||||
MemoryStoreDefaultDefinition definition = new(deploymentName, embeddingModelName);
|
||||
await aiProjectClient.MemoryStores.CreateMemoryStoreAsync(memoryStoreName, definition, "Sample memory store for Memory Search demo");
|
||||
Console.WriteLine("Memory store created.");
|
||||
}
|
||||
|
||||
Console.WriteLine("Storing memories from a prior conversation...");
|
||||
MemoryUpdateOptions memoryOptions = new(userScope) { UpdateDelay = 0 };
|
||||
memoryOptions.Items.Add(ResponseItem.CreateUserMessageItem("My name is Alice and I love programming in C#."));
|
||||
|
||||
MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync(
|
||||
memoryStoreName: memoryStoreName,
|
||||
options: memoryOptions,
|
||||
pollingInterval: 500);
|
||||
|
||||
if (updateResult.Status == MemoryStoreUpdateStatus.Failed)
|
||||
{
|
||||
throw new InvalidOperationException($"Memory update failed: {updateResult.ErrorDetails}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Memory update completed (status: {updateResult.Status}).\n");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+14
-5
@@ -17,7 +17,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("Starts a content generation workflow and returns the instance ID for tracking.")]
|
||||
public string StartContentGenerationWorkflow([Description("The topic for content generation")] string topic)
|
||||
{
|
||||
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", topic);
|
||||
this._logger.LogInformation("Starting content generation workflow for topic: {Topic}", SanitizeLogValue(topic));
|
||||
|
||||
const int MaxReviewAttempts = 3;
|
||||
const float ApprovalTimeoutHours = 72;
|
||||
@@ -34,7 +34,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
|
||||
this._logger.LogInformation(
|
||||
"Content generation workflow scheduled to be started for topic '{Topic}' with instance ID: {InstanceId}",
|
||||
topic,
|
||||
SanitizeLogValue(topic),
|
||||
instanceId);
|
||||
|
||||
return $"Workflow started with instance ID: {instanceId}";
|
||||
@@ -45,7 +45,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("The instance ID of the workflow to check")] string instanceId,
|
||||
[Description("Whether to include detailed information")] bool includeDetails = true)
|
||||
{
|
||||
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", instanceId);
|
||||
this._logger.LogInformation("Getting status for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
|
||||
|
||||
// Get the current agent context using the session-static property
|
||||
OrchestrationMetadata? status = await DurableAgentContext.Current.GetOrchestrationStatusAsync(
|
||||
@@ -54,7 +54,7 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", instanceId);
|
||||
this._logger.LogInformation("Workflow instance '{InstanceId}' not found.", SanitizeLogValue(instanceId));
|
||||
return new
|
||||
{
|
||||
instanceId,
|
||||
@@ -78,7 +78,16 @@ internal sealed class Tools(ILogger<Tools> logger)
|
||||
[Description("The instance ID of the workflow to submit feedback for")] string instanceId,
|
||||
[Description("Feedback to submit")] HumanApprovalResponse feedback)
|
||||
{
|
||||
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", instanceId);
|
||||
this._logger.LogInformation("Submitting human approval for workflow instance: {InstanceId}", SanitizeLogValue(instanceId));
|
||||
await DurableAgentContext.Current.RaiseOrchestrationEventAsync(instanceId, "HumanApproval", feedback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a user-provided value for safe inclusion in log entries
|
||||
/// by removing control characters that could be used for log forging.
|
||||
/// </summary>
|
||||
private static string SanitizeLogValue(string value) =>
|
||||
value
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
+20
-4
@@ -157,8 +157,8 @@ public sealed class FunctionTriggers
|
||||
|
||||
this._logger.LogInformation(
|
||||
"Resuming stream for conversation {ConversationId} from cursor: {Cursor}",
|
||||
conversationId,
|
||||
cursor ?? "(beginning)");
|
||||
SanitizeLogValue(conversationId),
|
||||
SanitizeLogValue(cursor) ?? "(beginning)");
|
||||
|
||||
// Check Accept header to determine response format
|
||||
// text/plain = raw text output (ideal for terminals)
|
||||
@@ -205,7 +205,7 @@ public sealed class FunctionTriggers
|
||||
{
|
||||
if (chunk.Error != null)
|
||||
{
|
||||
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", conversationId, chunk.Error);
|
||||
this._logger.LogWarning("Stream error for conversation {ConversationId}: {Error}", SanitizeLogValue(conversationId), chunk.Error);
|
||||
await WriteErrorAsync(httpContext.Response, chunk.Error, useSseFormat, cancellationToken);
|
||||
break;
|
||||
}
|
||||
@@ -224,7 +224,7 @@ public sealed class FunctionTriggers
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
this._logger.LogInformation("Client disconnected from stream {ConversationId}", conversationId);
|
||||
this._logger.LogInformation("Client disconnected from stream {ConversationId}", SanitizeLogValue(conversationId));
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
@@ -316,4 +316,20 @@ public sealed class FunctionTriggers
|
||||
|
||||
await response.WriteAsync(sb.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sanitizes a user-provided value for safe inclusion in log entries
|
||||
/// by removing control characters that could be used for log forging.
|
||||
/// </summary>
|
||||
private static string? SanitizeLogValue(string? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace("\r", string.Empty, StringComparison.Ordinal)
|
||||
.Replace("\n", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user