mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
22
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 |
@@ -3,6 +3,7 @@
|
||||
"image": "mcr.microsoft.com/devcontainers/dotnet",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/azure-cli:1.2.9": {},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {
|
||||
"version": "2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Scan open issues and PRs for stale follow-ups from external authors.
|
||||
|
||||
If a team member commented and the external author hasn't replied within
|
||||
DAYS_THRESHOLD days, post a reminder comment and add the 'needs-info' label.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from github import Auth, Github, GithubException
|
||||
from github.Issue import Issue
|
||||
from github.IssueComment import IssueComment
|
||||
|
||||
|
||||
PING_COMMENT = (
|
||||
"@{author}, friendly reminder — this issue is waiting on your response. "
|
||||
"Please share any updates when you get a chance. (This is an automated message.)"
|
||||
)
|
||||
LABEL = "needs-info"
|
||||
|
||||
|
||||
def get_team_members(g: Github, org: str, team_slug: str) -> set[str]:
|
||||
"""Fetch active team member usernames."""
|
||||
try:
|
||||
org_obj = g.get_organization(org)
|
||||
team = org_obj.get_team_by_slug(team_slug)
|
||||
return {m.login for m in team.get_members()}
|
||||
except GithubException as exc:
|
||||
if exc.status in (403, 404):
|
||||
print(
|
||||
f"ERROR: Failed to fetch team members for {org}/{team_slug} "
|
||||
f"(HTTP {exc.status}). Check that the token has the 'read:org' "
|
||||
f"scope and that the team slug '{team_slug}' is correct."
|
||||
)
|
||||
else:
|
||||
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def find_last_team_comment(
|
||||
comments: list[IssueComment], team_members: set[str]
|
||||
) -> IssueComment | None:
|
||||
"""Return the most recent comment from a team member, or None."""
|
||||
for comment in reversed(comments):
|
||||
if comment.user and comment.user.login in team_members:
|
||||
return comment
|
||||
return None
|
||||
|
||||
|
||||
def author_replied_after(
|
||||
comments: list[IssueComment], author: str, after: datetime
|
||||
) -> bool:
|
||||
"""Check if the issue author commented after the given timestamp."""
|
||||
for comment in comments:
|
||||
if (
|
||||
comment.user
|
||||
and comment.user.login == author
|
||||
and comment.created_at > after
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def should_ping(
|
||||
issue: Issue,
|
||||
team_members: set[str],
|
||||
days_threshold: int,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Determine whether this issue/PR should be pinged."""
|
||||
author = issue.user.login
|
||||
|
||||
# Skip if author is a team member
|
||||
if author in team_members:
|
||||
return False
|
||||
|
||||
# Skip if already labeled
|
||||
if any(label.name == LABEL for label in issue.labels):
|
||||
return False
|
||||
|
||||
# Skip if no comments at all
|
||||
if issue.comments == 0:
|
||||
return False
|
||||
|
||||
# Fetch comments once for both lookups
|
||||
comments = list(issue.get_comments())
|
||||
|
||||
# Find last team member comment
|
||||
last_team_comment = find_last_team_comment(comments, team_members)
|
||||
if last_team_comment is None:
|
||||
return False
|
||||
|
||||
# Skip if author replied after the last team comment
|
||||
if author_replied_after(comments, author, last_team_comment.created_at):
|
||||
return False
|
||||
|
||||
# Check if enough days have passed
|
||||
days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days
|
||||
if days_since < days_threshold:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ping(issue: Issue, dry_run: bool) -> bool:
|
||||
"""Post a reminder comment and add the needs-info label. Returns True on success."""
|
||||
author = issue.user.login
|
||||
kind = "PR" if issue.pull_request else "Issue"
|
||||
|
||||
if dry_run:
|
||||
print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})")
|
||||
return True
|
||||
|
||||
max_retries = 3
|
||||
commented = False
|
||||
labeled = False
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
if not commented:
|
||||
issue.create_comment(PING_COMMENT.format(author=author))
|
||||
commented = True
|
||||
if not labeled:
|
||||
issue.add_to_labels(LABEL)
|
||||
labeled = True
|
||||
print(f" Pinged {kind} #{issue.number} (@{author})")
|
||||
return True
|
||||
except Exception as exc:
|
||||
if attempt < max_retries:
|
||||
wait = 2 ** attempt # 2s, 4s
|
||||
print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if not token:
|
||||
print("ERROR: GITHUB_TOKEN environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
repository = os.environ.get("GITHUB_REPOSITORY")
|
||||
if not repository:
|
||||
print("ERROR: GITHUB_REPOSITORY environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
team_slug = os.environ.get("TEAM_SLUG")
|
||||
if not team_slug:
|
||||
print("ERROR: TEAM_SLUG environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4")
|
||||
try:
|
||||
days_threshold = int(days_threshold_raw)
|
||||
except ValueError:
|
||||
print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'")
|
||||
sys.exit(1)
|
||||
dry_run = os.environ.get("DRY_RUN", "false").lower() == "true"
|
||||
|
||||
org = repository.split("/")[0]
|
||||
|
||||
if dry_run:
|
||||
print("Running in DRY RUN mode — no comments or labels will be applied.\n")
|
||||
|
||||
g = Github(auth=Auth.Token(token))
|
||||
repo = g.get_repo(repository)
|
||||
|
||||
print(f"Fetching team members for {org}/{team_slug}...")
|
||||
team_members = get_team_members(g, org, team_slug)
|
||||
print(f"Found {len(team_members)} team members.\n")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
pinged = []
|
||||
failed = []
|
||||
scanned = 0
|
||||
|
||||
print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n")
|
||||
|
||||
for issue in repo.get_issues(state="open"):
|
||||
scanned += 1
|
||||
|
||||
if should_ping(issue, team_members, days_threshold, now):
|
||||
if ping(issue, dry_run):
|
||||
pinged.append(issue.number)
|
||||
else:
|
||||
failed.append(issue.number)
|
||||
|
||||
print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.")
|
||||
if pinged:
|
||||
print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}")
|
||||
if failed:
|
||||
print(f"Failed: {', '.join(f'#{n}' for n in failed)}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,293 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for stale_issue_pr_ping.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the script directory is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))
|
||||
|
||||
from stale_issue_pr_ping import (
|
||||
LABEL,
|
||||
PING_COMMENT,
|
||||
author_replied_after,
|
||||
find_last_team_comment,
|
||||
get_team_members,
|
||||
main,
|
||||
ping,
|
||||
should_ping,
|
||||
)
|
||||
|
||||
TEAM = {"alice", "bob"}
|
||||
NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_comment(login: str | None, created_at: datetime) -> MagicMock:
|
||||
"""Create a mock IssueComment."""
|
||||
c = MagicMock()
|
||||
if login is None:
|
||||
c.user = None
|
||||
else:
|
||||
c.user = MagicMock()
|
||||
c.user.login = login
|
||||
c.created_at = created_at
|
||||
return c
|
||||
|
||||
|
||||
def _make_label(name: str) -> MagicMock:
|
||||
lbl = MagicMock()
|
||||
lbl.name = name
|
||||
return lbl
|
||||
|
||||
|
||||
def _make_issue(
|
||||
author: str = "external",
|
||||
labels: list[str] | None = None,
|
||||
comment_count: int = 1,
|
||||
comments: list[MagicMock] | None = None,
|
||||
pull_request: bool = False,
|
||||
number: int = 42,
|
||||
) -> MagicMock:
|
||||
issue = MagicMock()
|
||||
issue.user = MagicMock()
|
||||
issue.user.login = author
|
||||
issue.number = number
|
||||
issue.labels = [_make_label(n) for n in (labels or [])]
|
||||
issue.comments = comment_count
|
||||
issue.pull_request = MagicMock() if pull_request else None
|
||||
if comments is not None:
|
||||
issue.get_comments.return_value = comments
|
||||
return issue
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_last_team_comment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFindLastTeamComment:
|
||||
def test_returns_last_team_comment(self):
|
||||
c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1, c2, c3], TEAM) is c3
|
||||
|
||||
def test_returns_none_when_no_team_comments(self):
|
||||
c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1], TEAM) is None
|
||||
|
||||
def test_returns_none_for_empty_list(self):
|
||||
assert find_last_team_comment([], TEAM) is None
|
||||
|
||||
def test_skips_deleted_user(self):
|
||||
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1, c2], TEAM) is c2
|
||||
|
||||
def test_only_deleted_users(self):
|
||||
c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc))
|
||||
assert find_last_team_comment([c1], TEAM) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# author_replied_after
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestAuthorRepliedAfter:
|
||||
def test_author_replied(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is True
|
||||
|
||||
def test_author_not_replied(self):
|
||||
after = datetime(2026, 3, 5, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
def test_different_user_replied(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
def test_deleted_user_comment(self):
|
||||
after = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc))
|
||||
assert author_replied_after([c1], "external", after) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_ping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldPing:
|
||||
def test_should_ping_stale_issue(self):
|
||||
team_comment = _make_comment("alice", NOW - timedelta(days=5))
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is True
|
||||
|
||||
def test_skip_team_member_author(self):
|
||||
issue = _make_issue(author="alice", comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_already_labeled(self):
|
||||
issue = _make_issue(labels=[LABEL], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_no_comments(self):
|
||||
issue = _make_issue(comment_count=0)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_no_team_comment(self):
|
||||
c = _make_comment("external", NOW - timedelta(days=5))
|
||||
issue = _make_issue(comments=[c], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_author_replied(self):
|
||||
team_c = _make_comment("alice", NOW - timedelta(days=5))
|
||||
author_c = _make_comment("external", NOW - timedelta(days=3))
|
||||
issue = _make_issue(comments=[team_c, author_c], comment_count=2)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_skip_not_enough_days(self):
|
||||
team_comment = _make_comment("alice", NOW - timedelta(days=2))
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is False
|
||||
|
||||
def test_aware_datetime_handled(self):
|
||||
"""Timezone-aware datetimes should not be mangled by astimezone."""
|
||||
aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc)
|
||||
team_comment = _make_comment("alice", aware_dt)
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
assert should_ping(issue, TEAM, 4, NOW) is True
|
||||
|
||||
def test_naive_datetime_handled(self):
|
||||
"""Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone."""
|
||||
naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None)
|
||||
team_comment = _make_comment("alice", naive_dt)
|
||||
issue = _make_issue(comments=[team_comment], comment_count=1)
|
||||
# astimezone on naive datetime treats it as local time; just verify no crash
|
||||
should_ping(issue, TEAM, 4, NOW)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPing:
|
||||
def test_dry_run(self, capsys):
|
||||
issue = _make_issue()
|
||||
assert ping(issue, dry_run=True) is True
|
||||
issue.create_comment.assert_not_called()
|
||||
assert "DRY RUN" in capsys.readouterr().out
|
||||
|
||||
def test_success(self, capsys):
|
||||
issue = _make_issue()
|
||||
assert ping(issue, dry_run=False) is True
|
||||
issue.create_comment.assert_called_once()
|
||||
issue.add_to_labels.assert_called_once_with(LABEL)
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_retry_on_failure(self, mock_sleep):
|
||||
issue = _make_issue()
|
||||
issue.create_comment.side_effect = [Exception("net error"), None]
|
||||
assert ping(issue, dry_run=False) is True
|
||||
assert issue.create_comment.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep):
|
||||
"""If create_comment succeeds but add_to_labels fails, retry should not re-comment."""
|
||||
issue = _make_issue()
|
||||
issue.add_to_labels.side_effect = [Exception("label error"), None]
|
||||
assert ping(issue, dry_run=False) is True
|
||||
# Comment should only be created once even though there were 2 attempts
|
||||
assert issue.create_comment.call_count == 1
|
||||
assert issue.add_to_labels.call_count == 2
|
||||
|
||||
@patch("stale_issue_pr_ping.time.sleep")
|
||||
def test_all_retries_fail(self, mock_sleep):
|
||||
issue = _make_issue()
|
||||
issue.create_comment.side_effect = Exception("permanent error")
|
||||
assert ping(issue, dry_run=False) is False
|
||||
assert issue.create_comment.call_count == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_team_members
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetTeamMembers:
|
||||
def test_success(self):
|
||||
g = MagicMock()
|
||||
member = MagicMock()
|
||||
member.login = "alice"
|
||||
g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member]
|
||||
assert get_team_members(g, "org", "my-team") == {"alice"}
|
||||
|
||||
def test_403_error_message(self, capsys):
|
||||
from github import GithubException
|
||||
|
||||
g = MagicMock()
|
||||
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
|
||||
403, {"message": "Forbidden"}, None
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "my-team")
|
||||
out = capsys.readouterr().out
|
||||
assert "read:org" in out
|
||||
assert "403" in out
|
||||
|
||||
def test_404_error_message(self, capsys):
|
||||
from github import GithubException
|
||||
|
||||
g = MagicMock()
|
||||
g.get_organization.return_value.get_team_by_slug.side_effect = GithubException(
|
||||
404, {"message": "Not Found"}, None
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "bad-slug")
|
||||
out = capsys.readouterr().out
|
||||
assert "read:org" in out
|
||||
assert "bad-slug" in out
|
||||
|
||||
def test_generic_error(self, capsys):
|
||||
g = MagicMock()
|
||||
g.get_organization.side_effect = RuntimeError("boom")
|
||||
with pytest.raises(SystemExit):
|
||||
get_team_members(g, "org", "team")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# main – env var validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMain:
|
||||
@patch.dict(os.environ, {
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GITHUB_REPOSITORY": "org/repo",
|
||||
"TEAM_SLUG": "my-team",
|
||||
"DAYS_THRESHOLD": "abc",
|
||||
}, clear=True)
|
||||
def test_invalid_days_threshold(self, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
assert "numeric" in capsys.readouterr().out
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"GITHUB_TOKEN": "tok",
|
||||
"GITHUB_REPOSITORY": "org/repo",
|
||||
}, clear=True)
|
||||
def test_missing_team_slug(self, capsys):
|
||||
with pytest.raises(SystemExit):
|
||||
main()
|
||||
assert "TEAM_SLUG" in capsys.readouterr().out
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run fmt, lint, pyright in parallel across packages
|
||||
- name: Run syntax and pyright across packages
|
||||
run: uv run poe check-packages
|
||||
|
||||
samples-markdown:
|
||||
@@ -104,10 +104,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
env:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
- name: Run samples lint
|
||||
run: uv run poe samples-lint
|
||||
- name: Run samples syntax check
|
||||
run: uv run poe samples-syntax
|
||||
- name: Run samples checks
|
||||
run: uv run poe check -S
|
||||
- name: Run markdown code lint
|
||||
run: uv run poe markdown-code-lint
|
||||
|
||||
@@ -140,4 +138,4 @@ jobs:
|
||||
- name: Run Mypy
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.event.pull_request.base.ref || github.base_ref || 'main' }}
|
||||
run: uv run poe ci-mypy
|
||||
run: uv run python scripts/workspace_poe_tasks.py ci-mypy
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
id: validate_ranges
|
||||
# Keep workflow running so we can still publish diagnostics from this run.
|
||||
continue-on-error: true
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --project "*"
|
||||
run: uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
working-directory: ./python
|
||||
|
||||
- name: Upload dependency range report
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
cat > "${PR_BODY_FILE}" <<'EOF'
|
||||
This PR was generated by the dependency range validation workflow.
|
||||
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --project "*"`
|
||||
- Ran `uv run poe validate-dependency-bounds-project --mode upper --package "*"`
|
||||
- Updated package dependency bounds
|
||||
- Refreshed `python/uv.lock` with `uv lock --upgrade`
|
||||
EOF
|
||||
|
||||
@@ -48,9 +48,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
|
||||
|
||||
@@ -100,9 +100,8 @@ jobs:
|
||||
os: ${{ runner.os }}
|
||||
- name: Test with pytest (unit tests only)
|
||||
run: >
|
||||
uv run poe all-tests
|
||||
uv run poe test -A
|
||||
-m "not integration"
|
||||
-n logical --dist worksteal
|
||||
--timeout=120 --session-timeout=900 --timeout_method thread
|
||||
--retries 2 --retry-delay 5
|
||||
working-directory: ./python
|
||||
|
||||
@@ -32,13 +32,13 @@ 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
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# Unit tests
|
||||
- name: Run all tests
|
||||
run: uv run poe all-tests ${{ matrix.python-version == '3.10' && '--ignore-glob=packages/github_copilot/**' || '' }}
|
||||
run: uv run poe test -A
|
||||
working-directory: ./python
|
||||
|
||||
# Surface failing tests
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Stale issue and PR ping
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Midnight UTC daily
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days_threshold:
|
||||
description: 'Days of silence before pinging the author'
|
||||
required: false
|
||||
default: '4'
|
||||
dry_run:
|
||||
description: 'Log what would be pinged without taking action'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
|
||||
concurrency:
|
||||
group: stale-issue-pr-ping
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ping_stale:
|
||||
name: "Ping stale issues and PRs"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install PyGithub==2.6.0
|
||||
|
||||
- name: Run stale issue/PR ping
|
||||
run: python .github/scripts/stale_issue_pr_ping.py
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }}
|
||||
DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
@@ -149,6 +149,7 @@
|
||||
<!-- Symbols -->
|
||||
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
|
||||
<!-- Toolset -->
|
||||
<PackageVersion Include="ReferenceTrimmer" Version="3.4.5" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.100" />
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-4
@@ -12,13 +12,9 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.Json" />
|
||||
<PackageReference Include="Microsoft.Agents.ObjectModel.PowerFx" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Declarative\Microsoft.Agents.AI.Declarative.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -61,6 +61,12 @@ public static class Program
|
||||
{
|
||||
Console.WriteLine($"{outputEvent}");
|
||||
}
|
||||
|
||||
if (evt is WorkflowErrorEvent errorEvent)
|
||||
{
|
||||
Console.WriteLine($"Workflow error: {errorEvent.Exception?.Message}");
|
||||
Console.WriteLine($"Details: {errorEvent.Exception}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +181,9 @@ internal sealed class FeedbackEvent(FeedbackResult feedbackResult) : WorkflowEve
|
||||
/// <summary>
|
||||
/// A custom executor that uses an AI agent to provide feedback on a slogan.
|
||||
/// </summary>
|
||||
internal sealed class FeedbackExecutor : Executor<SloganResult>
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed partial class FeedbackExecutor : Executor<SloganResult>
|
||||
{
|
||||
private readonly AIAgent _agent;
|
||||
private AgentSession? _session;
|
||||
|
||||
-1
@@ -14,7 +14,6 @@
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore\Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,10 +9,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.A2A\Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\AgentWebChat.ServiceDefaults\AgentWebChat.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<Project>
|
||||
|
||||
<Import Project="../Directory.Build.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ReferenceTrimmer" PrivateAssets="all" IncludeAssets="build;analyzers;buildTransitive" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -16,12 +16,9 @@
|
||||
<Description>Provides Microsoft Agent Framework support for Agent-User Interaction (AG-UI) protocol client functionality.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="System.Net.ServerSentEvents" />
|
||||
<PackageReference Include="System.Net.Http.Json" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
|
||||
@@ -104,17 +104,15 @@ public sealed class InMemoryChatHistoryProvider : ChatHistoryProvider
|
||||
{
|
||||
State state = this._sessionState.GetOrInitializeState(context.Session);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
// Reduce existing messages before adding new messages from the current turn.
|
||||
// This ensures messages from the current turn (including function calls and tool results)
|
||||
// are always preserved in full and are not immediately reduced.
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Add request and response messages to the provider
|
||||
var allNewMessages = context.RequestMessages.Concat(context.ResponseMessages ?? []);
|
||||
state.Messages.AddRange(allNewMessages);
|
||||
|
||||
if (this.ReducerTriggerEvent is InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded && this.ChatReducer is not null)
|
||||
{
|
||||
// Apply pre-write reduction strategy if configured
|
||||
await ReduceMessagesAsync(this.ChatReducer, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReduceMessagesAsync(IChatReducer reducer, State state, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
<PackageReference Include="Microsoft.PowerFx.Interpreter" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="System.CodeDom" />
|
||||
<PackageReference Include="System.CodeDom" TreatAsUsed="true" />
|
||||
<PackageReference Include="System.Collections.Immutable" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ internal static class SemanticAnalyzer
|
||||
string classKey = GetClassKey(classSymbol);
|
||||
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
|
||||
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
|
||||
bool configureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
|
||||
// Extract class metadata
|
||||
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
|
||||
@@ -97,7 +97,7 @@ internal static class SemanticAnalyzer
|
||||
return new MethodAnalysisResult(
|
||||
classKey, @namespace, className, genericParameters, isNested, containingTypeChain,
|
||||
baseHasConfigureProtocol, classSendTypes, classYieldTypes,
|
||||
isPartialClass, derivesFromExecutor, configureProtocol,
|
||||
isPartialClass, derivesFromExecutor, hasManualConfigureProtocol,
|
||||
classLocation,
|
||||
handler,
|
||||
Diagnostics: new ImmutableEquatableArray<DiagnosticInfo>(methodDiagnostics.ToImmutable()));
|
||||
@@ -149,7 +149,7 @@ internal static class SemanticAnalyzer
|
||||
return AnalysisResult.WithDiagnostics(allDiagnostics.ToImmutable());
|
||||
}
|
||||
|
||||
if (first.HasManualConfigureRoutes)
|
||||
if (first.HasManualConfigureProtocol)
|
||||
{
|
||||
allDiagnostics.Add(Diagnostic.Create(
|
||||
DiagnosticDescriptors.ConfigureProtocolAlreadyDefined,
|
||||
@@ -212,6 +212,7 @@ internal static class SemanticAnalyzer
|
||||
bool isPartialClass = IsPartialClass(classSymbol, cancellationToken);
|
||||
bool derivesFromExecutor = DerivesFromExecutor(classSymbol);
|
||||
bool hasManualConfigureProtocol = HasConfigureProtocolDefined(classSymbol);
|
||||
bool baseHasConfigureProtocol = BaseHasConfigureProtocol(classSymbol);
|
||||
|
||||
string? @namespace = classSymbol.ContainingNamespace?.IsGlobalNamespace == true
|
||||
? null
|
||||
@@ -241,6 +242,7 @@ internal static class SemanticAnalyzer
|
||||
isPartialClass,
|
||||
derivesFromExecutor,
|
||||
hasManualConfigureProtocol,
|
||||
baseHasConfigureProtocol,
|
||||
classLocation,
|
||||
typeName,
|
||||
attributeKind));
|
||||
@@ -321,7 +323,7 @@ internal static class SemanticAnalyzer
|
||||
first.GenericParameters,
|
||||
first.IsNested,
|
||||
first.ContainingTypeChain,
|
||||
BaseHasConfigureProtocol: false, // Not relevant for protocol-only
|
||||
first.BaseHasConfigureProtocol,
|
||||
Handlers: ImmutableEquatableArray<HandlerInfo>.Empty,
|
||||
ClassSendTypes: new ImmutableEquatableArray<string>(sendTypes.ToImmutable()),
|
||||
ClassYieldTypes: new ImmutableEquatableArray<string>(yieldTypes.ToImmutable()));
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// <summary>
|
||||
/// Represents protocol type information extracted from class-level [SendsMessage] or [YieldsOutput] attributes.
|
||||
/// Used by the incremental generator pipeline to capture classes that declare protocol types
|
||||
/// but may not have [MessageHandler] methods (e.g., when ConfigureRoutes is manually implemented).
|
||||
/// but may not have [MessageHandler] methods (e.g., when ConfigureProtocol is manually implemented).
|
||||
/// </summary>
|
||||
/// <param name="ClassKey">Unique identifier for the class (fully qualified name).</param>
|
||||
/// <param name="Namespace">The namespace of the class.</param>
|
||||
@@ -15,7 +15,8 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// <param name="ContainingTypeChain">The chain of containing types for nested classes. Empty if not nested.</param>
|
||||
/// <param name="IsPartialClass">Whether the class is declared as partial.</param>
|
||||
/// <param name="DerivesFromExecutor">Whether the class derives from Executor.</param>
|
||||
/// <param name="HasManualConfigureRoutes">Whether the class has a manually defined ConfigureRoutes method.</param>
|
||||
/// <param name="HasManualConfigureProtocol">Whether the class has a manually defined ConfigureProtocol method.</param>
|
||||
/// <param name="BaseHasConfigureProtocol">Whether a base class already overrides ConfigureProtocol.</param>
|
||||
/// <param name="ClassLocation">Location info for diagnostics.</param>
|
||||
/// <param name="TypeName">The fully qualified type name from the attribute.</param>
|
||||
/// <param name="AttributeKind">Whether this is from a SendsMessage or YieldsOutput attribute.</param>
|
||||
@@ -28,7 +29,8 @@ internal sealed record ClassProtocolInfo(
|
||||
string ContainingTypeChain,
|
||||
bool IsPartialClass,
|
||||
bool DerivesFromExecutor,
|
||||
bool HasManualConfigureRoutes,
|
||||
bool HasManualConfigureProtocol,
|
||||
bool BaseHasConfigureProtocol,
|
||||
DiagnosticLocationInfo? ClassLocation,
|
||||
string TypeName,
|
||||
ProtocolAttributeKind AttributeKind)
|
||||
@@ -38,5 +40,5 @@ internal sealed record ClassProtocolInfo(
|
||||
/// </summary>
|
||||
public static ClassProtocolInfo Empty { get; } = new(
|
||||
string.Empty, null, string.Empty, null, false, string.Empty,
|
||||
false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
|
||||
false, false, false, false, null, string.Empty, ProtocolAttributeKind.Send);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Generators.Models;
|
||||
/// Uses value-equatable types to support incremental generator caching.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureRoutes)
|
||||
/// Class-level validation (IsPartialClass, DerivesFromExecutor, HasManualConfigureProtocol)
|
||||
/// is extracted here but validated once per class in CombineMethodResults to avoid
|
||||
/// redundant validation work when a class has multiple handlers.
|
||||
/// </remarks>
|
||||
@@ -29,7 +29,7 @@ internal sealed record MethodAnalysisResult(
|
||||
// Class-level facts (used for validation in CombineMethodResults)
|
||||
bool IsPartialClass,
|
||||
bool DerivesFromExecutor,
|
||||
bool HasManualConfigureRoutes,
|
||||
bool HasManualConfigureProtocol,
|
||||
|
||||
// Class location for diagnostics (value-equatable)
|
||||
DiagnosticLocationInfo? ClassLocation,
|
||||
|
||||
@@ -3,25 +3,25 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Execution;
|
||||
|
||||
internal sealed class FanInEdgeState
|
||||
{
|
||||
private List<PortableMessageEnvelope> _pendingMessages;
|
||||
private readonly object _syncLock = new();
|
||||
|
||||
public FanInEdgeState(FanInEdgeData fanInEdge)
|
||||
{
|
||||
this.SourceIds = fanInEdge.SourceIds.ToArray();
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
|
||||
this._pendingMessages = [];
|
||||
this.PendingMessages = [];
|
||||
}
|
||||
|
||||
public string[] SourceIds { get; }
|
||||
public HashSet<string> Unseen { get; private set; }
|
||||
public List<PortableMessageEnvelope> PendingMessages => this._pendingMessages;
|
||||
public List<PortableMessageEnvelope> PendingMessages { get; private set; }
|
||||
|
||||
[JsonConstructor]
|
||||
public FanInEdgeState(string[] sourceIds, HashSet<string> unseen, List<PortableMessageEnvelope> pendingMessages)
|
||||
@@ -29,28 +29,35 @@ internal sealed class FanInEdgeState
|
||||
this.SourceIds = sourceIds;
|
||||
this.Unseen = unseen;
|
||||
|
||||
this._pendingMessages = pendingMessages;
|
||||
this.PendingMessages = pendingMessages;
|
||||
}
|
||||
|
||||
public IEnumerable<IGrouping<ExecutorIdentity, MessageEnvelope>>? ProcessMessage(string sourceId, MessageEnvelope envelope)
|
||||
{
|
||||
this.PendingMessages.Add(new(envelope));
|
||||
this.Unseen.Remove(sourceId);
|
||||
List<PortableMessageEnvelope>? takenMessages = null;
|
||||
|
||||
if (this.Unseen.Count == 0)
|
||||
// Serialize concurrent calls from parallel executor tasks during superstep execution.
|
||||
// NOTE - IMPORTANT: If this ProcessMessage method ever becomes async, replace this lock with an async friendly solution to avoid deadlocks.
|
||||
lock (this._syncLock)
|
||||
{
|
||||
List<PortableMessageEnvelope> takenMessages = Interlocked.Exchange(ref this._pendingMessages, []);
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
this.PendingMessages.Add(new(envelope));
|
||||
this.Unseen.Remove(sourceId);
|
||||
|
||||
if (takenMessages.Count == 0)
|
||||
if (this.Unseen.Count == 0)
|
||||
{
|
||||
return null;
|
||||
takenMessages = this.PendingMessages;
|
||||
this.PendingMessages = [];
|
||||
this.Unseen = [.. this.SourceIds];
|
||||
}
|
||||
|
||||
return takenMessages.Select(portable => portable.ToMessageEnvelope())
|
||||
.GroupBy(keySelector: messageEnvelope => messageEnvelope.Source);
|
||||
}
|
||||
|
||||
return null;
|
||||
if (takenMessages is null || takenMessages.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return takenMessages
|
||||
.Select(portable => portable.ToMessageEnvelope())
|
||||
.GroupBy(messageEnvelope => messageEnvelope.Source);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Provides extension methods for <see cref="CompactionStrategy"/>.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public static class ChatStrategyExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an <see cref="IChatReducer"/> that applies this <see cref="CompactionStrategy"/> to reduce a list of messages.
|
||||
/// </summary>
|
||||
/// <param name="strategy">The compaction strategy to wrap as an <see cref="IChatReducer"/>.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IChatReducer"/> that, on each call to <see cref="IChatReducer.ReduceAsync"/>, builds a
|
||||
/// <see cref="CompactionMessageIndex"/> from the supplied messages and applies the strategy's compaction logic,
|
||||
/// returning the resulting included messages.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// This allows any <see cref="CompactionStrategy"/> to be used wherever an <see cref="IChatReducer"/> is expected,
|
||||
/// bridging the compaction pipeline into systems bound to the <c>Microsoft.Extensions.AI</c> <see cref="IChatReducer"/> contract.
|
||||
/// </remarks>
|
||||
public static IChatReducer AsChatReducer(this CompactionStrategy strategy)
|
||||
{
|
||||
Throw.IfNull(strategy);
|
||||
|
||||
return new CompactionStrategyChatReducer(strategy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> adapter that delegates to a <see cref="CompactionStrategy"/>.
|
||||
/// </summary>
|
||||
private sealed class CompactionStrategyChatReducer : IChatReducer
|
||||
{
|
||||
private readonly CompactionStrategy _strategy;
|
||||
|
||||
public CompactionStrategyChatReducer(CompactionStrategy strategy)
|
||||
{
|
||||
this._strategy = strategy;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
CompactionMessageIndex index = CompactionMessageIndex.Create([.. messages]);
|
||||
await this._strategy.CompactAsync(index, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return index.GetIncludedMessages();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
@@ -30,6 +31,12 @@ namespace Microsoft.Agents.AI.Compaction;
|
||||
/// </code>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A custom <see cref="ToolCallFormatter"/> can be supplied to override the default YAML-like
|
||||
/// summary format. The formatter receives the <see cref="CompactionMessageGroup"/> being collapsed
|
||||
/// and must return the replacement summary string. <see cref="DefaultToolCallFormatter"/> is the
|
||||
/// built-in default and can be reused inside a custom formatter when needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="MinimumPreservedGroups"/> is a hard floor: even if the <see cref="CompactionStrategy.Target"/>
|
||||
/// has not been reached, compaction will not touch the last <see cref="MinimumPreservedGroups"/> non-system groups.
|
||||
/// </para>
|
||||
@@ -62,7 +69,10 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
/// An optional target condition that controls when compaction stops. When <see langword="null"/>,
|
||||
/// defaults to the inverse of the <paramref name="trigger"/> — compaction stops as soon as the trigger would no longer fire.
|
||||
/// </param>
|
||||
public ToolResultCompactionStrategy(CompactionTrigger trigger, int minimumPreservedGroups = DefaultMinimumPreserved, CompactionTrigger? target = null)
|
||||
public ToolResultCompactionStrategy(
|
||||
CompactionTrigger trigger,
|
||||
int minimumPreservedGroups = DefaultMinimumPreserved,
|
||||
CompactionTrigger? target = null)
|
||||
: base(trigger, target)
|
||||
{
|
||||
this.MinimumPreservedGroups = EnsureNonNegative(minimumPreservedGroups);
|
||||
@@ -74,6 +84,13 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
/// </summary>
|
||||
public int MinimumPreservedGroups { get; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional custom formatter that converts a <see cref="CompactionMessageGroup"/> into a summary string.
|
||||
/// When <see langword="null"/>, <see cref="DefaultToolCallFormatter"/> is used, which produces a YAML-like
|
||||
/// block listing each tool name and its results.
|
||||
/// </summary>
|
||||
public Func<CompactionMessageGroup, string>? ToolCallFormatter { get; init; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ValueTask<bool> CompactCoreAsync(CompactionMessageIndex index, ILogger logger, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -120,7 +137,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
int idx = eligibleIndices[e] + offset;
|
||||
CompactionMessageGroup group = index.Groups[idx];
|
||||
|
||||
string summary = BuildToolCallSummary(group);
|
||||
string summary = (this.ToolCallFormatter ?? DefaultToolCallFormatter).Invoke(group);
|
||||
|
||||
// Exclude the original group and insert a collapsed replacement
|
||||
group.IsExcluded = true;
|
||||
@@ -145,14 +162,18 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a concise summary string for a tool call group, including tool names,
|
||||
/// The default formatter that produces a YAML-like summary of tool call groups, including tool names,
|
||||
/// results, and deduplication counts for repeated tool names.
|
||||
/// </summary>
|
||||
private static string BuildToolCallSummary(CompactionMessageGroup group)
|
||||
/// <remarks>
|
||||
/// This is the formatter used when no custom <see cref="ToolCallFormatter"/> is supplied.
|
||||
/// It can be referenced directly in a custom formatter to augment or wrap the default output.
|
||||
/// </remarks>
|
||||
public static string DefaultToolCallFormatter(CompactionMessageGroup group)
|
||||
{
|
||||
// Collect function calls (callId, name) and results (callId → result text)
|
||||
List<(string CallId, string Name)> functionCalls = [];
|
||||
Dictionary<string, string> resultsByCallId = new();
|
||||
Dictionary<string, string> resultsByCallId = [];
|
||||
List<string> plainTextResults = [];
|
||||
|
||||
foreach (ChatMessage message in group.Messages)
|
||||
@@ -187,7 +208,7 @@ public sealed class ToolResultCompactionStrategy : CompactionStrategy
|
||||
// grouping by tool name while preserving first-seen order.
|
||||
int plainTextIdx = 0;
|
||||
List<string> orderedNames = [];
|
||||
Dictionary<string, List<string>> groupedResults = new();
|
||||
Dictionary<string, List<string>> groupedResults = [];
|
||||
|
||||
foreach ((string callId, string name) in functionCalls)
|
||||
{
|
||||
|
||||
@@ -175,15 +175,23 @@ public sealed partial class FileAgentSkillsProvider : AIContextProvider
|
||||
try
|
||||
{
|
||||
_ = string.Format(optionsInstructions, string.Empty);
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt is not a valid format string. It must contain a '{0}' placeholder and escape any literal '{' or '}' by doubling them ('{{' or '}}').",
|
||||
"The provided SkillsInstructionPrompt is not a valid format string.",
|
||||
nameof(options),
|
||||
ex);
|
||||
}
|
||||
|
||||
if (optionsInstructions.IndexOf("{0}", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"The provided SkillsInstructionPrompt must contain a '{0}' placeholder for the generated skills list.",
|
||||
nameof(options));
|
||||
}
|
||||
|
||||
promptTemplate = optionsInstructions;
|
||||
}
|
||||
|
||||
if (skills.Count == 0)
|
||||
|
||||
+5
-62
@@ -243,8 +243,7 @@ public class InMemoryChatHistoryProviderTests
|
||||
var session = CreateMockSession();
|
||||
|
||||
// Arrange
|
||||
// Existing messages in state from a previous turn.
|
||||
var existingMessages = new List<ChatMessage>
|
||||
var originalMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi there!")
|
||||
@@ -254,78 +253,22 @@ public class InMemoryChatHistoryProviderTests
|
||||
new(ChatRole.User, "Reduced")
|
||||
};
|
||||
|
||||
// New messages being added in the current turn.
|
||||
var newRequestMessage = new ChatMessage(ChatRole.User, "New message");
|
||||
var newResponseMessage = new ChatMessage(ChatRole.Assistant, "New response");
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()))
|
||||
.Setup(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(reducedMessages);
|
||||
|
||||
var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded });
|
||||
provider.SetMessages(session, new List<ChatMessage>(existingMessages));
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, [newRequestMessage], [newResponseMessage]);
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, originalMessages, []);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
// The reducer is called on existing messages before the new ones are added.
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(existingMessages)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
|
||||
// Final state: reduced existing messages + new current-turn messages (preserved in full).
|
||||
var messages = provider.GetMessages(session);
|
||||
Assert.Equal(3, messages.Count);
|
||||
Assert.Single(messages);
|
||||
Assert.Equal("Reduced", messages[0].Text);
|
||||
Assert.Equal("New message", messages[1].Text);
|
||||
Assert.Equal("New response", messages[2].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddMessagesAsync_WithReducer_AfterMessageAdded_PreservesCurrentTurnFunctionCallsAsync()
|
||||
{
|
||||
var session = CreateMockSession();
|
||||
|
||||
// Arrange - verify that function call and tool result messages from the current turn are preserved
|
||||
// even when a reducer is configured with AfterMessageAdded trigger. The reducer should only
|
||||
// be applied to existing (previous-turn) messages, not to the new messages being added.
|
||||
var existingMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "Previous question"),
|
||||
new(ChatRole.Assistant, "Previous answer")
|
||||
};
|
||||
|
||||
var reducerMock = new Mock<IChatReducer>();
|
||||
reducerMock
|
||||
.Setup(r => r.ReduceAsync(It.IsAny<IEnumerable<ChatMessage>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([]); // Simulates an aggressive reducer that clears all messages it receives
|
||||
|
||||
var provider = new InMemoryChatHistoryProvider(new() { ChatReducer = reducerMock.Object, ReducerTriggerEvent = InMemoryChatHistoryProviderOptions.ChatReducerTriggerEvent.AfterMessageAdded });
|
||||
provider.SetMessages(session, new List<ChatMessage>(existingMessages));
|
||||
|
||||
var requestMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.User, "What is the weather in Taggia?")
|
||||
};
|
||||
var responseMessages = new List<ChatMessage>
|
||||
{
|
||||
new(ChatRole.Assistant, [new FunctionCallContent("call1", "GetWeather", new Dictionary<string, object?> { ["location"] = "Taggia" })]),
|
||||
new(ChatRole.Tool, [new FunctionResultContent("call1", "Cloudy with a high of 15°C")]),
|
||||
new(ChatRole.Assistant, "The weather in Taggia is cloudy with a high of 15°C.")
|
||||
};
|
||||
|
||||
// Act
|
||||
var context = new ChatHistoryProvider.InvokedContext(s_mockAgent, session, requestMessages, responseMessages);
|
||||
await provider.InvokedAsync(context, CancellationToken.None);
|
||||
|
||||
// Assert - all current-turn messages (including function call and tool result) are preserved
|
||||
var messages = provider.GetMessages(session);
|
||||
Assert.Equal(4, messages.Count);
|
||||
Assert.Equal("What is the weather in Taggia?", messages[0].Text);
|
||||
Assert.True(messages[1].Contents.OfType<FunctionCallContent>().Any(), "Function call message should be preserved");
|
||||
Assert.True(messages[2].Contents.OfType<FunctionResultContent>().Any(), "Tool result message should be preserved");
|
||||
Assert.Equal("The weather in Taggia is cloudy with a high of 15°C.", messages[3].Text);
|
||||
reducerMock.Verify(r => r.ReduceAsync(It.Is<List<ChatMessage>>(x => x.SequenceEqual(originalMessages)), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
-1
@@ -8,7 +8,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
-1
@@ -10,7 +10,6 @@
|
||||
|
||||
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible($(TargetFramework), 'net10.0'))">
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
|
||||
<PackageReference Include="System.Linq.AsyncEnumerable" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
-1
@@ -6,7 +6,6 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedIntegrationTestCode>true</InjectSharedIntegrationTestCode>
|
||||
<InjectSharedBuildTestCode>true</InjectSharedBuildTestCode>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+36
@@ -127,6 +127,42 @@ public sealed class FileAgentSkillsProviderTests : IDisposable
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_PromptWithoutPlaceholder_ThrowsArgumentException()
|
||||
{
|
||||
// Arrange -- valid format string but missing the required placeholder
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "No placeholder here"
|
||||
};
|
||||
|
||||
var ex = Assert.Throws<ArgumentException>(() => new FileAgentSkillsProvider(this._testRoot, options));
|
||||
Assert.Contains("{0}", ex.Message);
|
||||
Assert.Equal("options", ex.ParamName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Constructor_PromptWithPlaceholder_AppliesCustomTemplateAsync()
|
||||
{
|
||||
// Arrange — valid custom template with {0} placeholder
|
||||
this.CreateSkill("custom-tpl-skill", "Custom template skill", "Body.");
|
||||
var options = new FileAgentSkillsProviderOptions
|
||||
{
|
||||
SkillsInstructionPrompt = "== Skills ==\n{0}\n== End =="
|
||||
};
|
||||
var provider = new FileAgentSkillsProvider(this._testRoot, options);
|
||||
var invokingContext = new AIContextProvider.InvokingContext(this._agent, session: null, new AIContext());
|
||||
|
||||
// Act
|
||||
var result = await provider.InvokingAsync(invokingContext, CancellationToken.None);
|
||||
|
||||
// Assert — the custom template wraps the skill list
|
||||
Assert.NotNull(result.Instructions);
|
||||
Assert.StartsWith("== Skills ==", result.Instructions);
|
||||
Assert.Contains("custom-tpl-skill", result.Instructions);
|
||||
Assert.Contains("== End ==", result.Instructions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokingCoreAsync_SkillNamesAreXmlEscapedAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.Compaction;
|
||||
|
||||
/// <summary>
|
||||
/// Contains tests for the <see cref="ChatStrategyExtensions"/> class.
|
||||
/// </summary>
|
||||
public class ChatStrategyExtensionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void AsChatReducerNullStrategyThrows()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentNullException>(() => ((CompactionStrategy)null!).AsChatReducer());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AsChatReducerReturnsIChatReducer()
|
||||
{
|
||||
// Arrange
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
|
||||
|
||||
// Act
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(reducer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncReturnsAllMessagesWhenStrategyDoesNotCompactAsync()
|
||||
{
|
||||
// Arrange — trigger never fires, so no compaction occurs
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Never);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.Assistant, "Hi!"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(messages, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncCompactsMessagesWhenStrategyFiresAsync()
|
||||
{
|
||||
// Arrange — reducer keeps only the last message
|
||||
ChatReducerCompactionStrategy strategy = new(
|
||||
new TakeLastReducer(1),
|
||||
CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "First"),
|
||||
new(ChatRole.Assistant, "Response 1"),
|
||||
new(ChatRole.User, "Second"),
|
||||
];
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync(messages, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
List<ChatMessage> resultList = [.. result];
|
||||
Assert.Single(resultList);
|
||||
Assert.Equal("Second", resultList[0].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncPassesCancellationTokenToStrategyAsync()
|
||||
{
|
||||
// Arrange
|
||||
using CancellationTokenSource cts = new();
|
||||
CancellationToken capturedToken = default;
|
||||
|
||||
CapturingReducer capturingReducer = new(token => capturedToken = token);
|
||||
ChatReducerCompactionStrategy strategy = new(capturingReducer, CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
List<ChatMessage> messages =
|
||||
[
|
||||
new(ChatRole.User, "Hello"),
|
||||
new(ChatRole.User, "World"),
|
||||
];
|
||||
|
||||
// Act
|
||||
await reducer.ReduceAsync(messages, cts.Token);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(cts.Token, capturedToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReduceAsyncEmptyMessagesReturnsEmptyAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatReducerCompactionStrategy strategy = new(new IdentityReducer(), CompactionTriggers.Always);
|
||||
IChatReducer reducer = strategy.AsChatReducer();
|
||||
|
||||
// Act
|
||||
IEnumerable<ChatMessage> result = await reducer.ReduceAsync([], CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that returns messages unchanged.
|
||||
/// </summary>
|
||||
private sealed class IdentityReducer : IChatReducer
|
||||
{
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that keeps only the last <c>n</c> messages.
|
||||
/// </summary>
|
||||
private sealed class TakeLastReducer : IChatReducer
|
||||
{
|
||||
private readonly int _count;
|
||||
|
||||
public TakeLastReducer(int count) => this._count = count;
|
||||
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(messages.Reverse().Take(this._count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="IChatReducer"/> that captures the <see cref="CancellationToken"/> passed to <see cref="ReduceAsync"/>.
|
||||
/// </summary>
|
||||
private sealed class CapturingReducer : IChatReducer
|
||||
{
|
||||
private readonly Action<CancellationToken> _capture;
|
||||
|
||||
public CapturingReducer(Action<CancellationToken> capture) => this._capture = capture;
|
||||
|
||||
public Task<IEnumerable<ChatMessage>> ReduceAsync(IEnumerable<ChatMessage> messages, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._capture(cancellationToken);
|
||||
IEnumerable<ChatMessage> reducedMessages = [messages.Reverse().First()];
|
||||
return Task.FromResult(reducedMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Compaction;
|
||||
@@ -348,4 +349,90 @@ public class ToolResultCompactionStrategyTests
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Tool Calls]\nget_weather:\n - Sunny\n - Rainy\nsearch_docs:\n - Found 3 docs", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncUsesCustomFormatterAsync()
|
||||
{
|
||||
// Arrange — custom formatter that produces a collapsed message count
|
||||
static string CustomFormatter(CompactionMessageGroup group) =>
|
||||
$"[Collapsed: {group.Messages.Count} messages]";
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1)
|
||||
{
|
||||
ToolCallFormatter = CustomFormatter,
|
||||
};
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "get_weather")]),
|
||||
new ChatMessage(ChatRole.Tool, "Sunny"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
bool result = await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — custom formatter output used instead of default YAML-like format
|
||||
Assert.True(result);
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("[Collapsed: 2 messages]", included[1].Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallFormatterPropertyIsNullWhenNoneProvided()
|
||||
{
|
||||
// Arrange
|
||||
ToolResultCompactionStrategy strategy = new(CompactionTriggers.Always);
|
||||
|
||||
// Assert — ToolCallFormatter is null when no custom formatter is provided
|
||||
Assert.Null(strategy.ToolCallFormatter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToolCallFormatterPropertyReturnsCustomFormatterWhenProvided()
|
||||
{
|
||||
// Arrange
|
||||
Func<CompactionMessageGroup, string> customFormatter = static _ => "custom";
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
CompactionTriggers.Always)
|
||||
{
|
||||
ToolCallFormatter = customFormatter
|
||||
};
|
||||
|
||||
// Assert — ToolCallFormatter is the injected custom function
|
||||
Assert.Same(customFormatter, strategy.ToolCallFormatter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompactAsyncCustomFormatterCanDelegateToDefaultAsync()
|
||||
{
|
||||
// Arrange — custom formatter that wraps the default output
|
||||
static string WrappingFormatter(CompactionMessageGroup group) =>
|
||||
$"CUSTOM_PREFIX\n{ToolResultCompactionStrategy.DefaultToolCallFormatter(group)}";
|
||||
|
||||
ToolResultCompactionStrategy strategy = new(
|
||||
trigger: _ => true,
|
||||
minimumPreservedGroups: 1)
|
||||
{
|
||||
ToolCallFormatter = WrappingFormatter
|
||||
};
|
||||
|
||||
CompactionMessageIndex groups = CompactionMessageIndex.Create(
|
||||
[
|
||||
new ChatMessage(ChatRole.User, "Q1"),
|
||||
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("c1", "fn")]),
|
||||
new ChatMessage(ChatRole.Tool, "result"),
|
||||
new ChatMessage(ChatRole.User, "Q2"),
|
||||
]);
|
||||
|
||||
// Act
|
||||
await strategy.CompactAsync(groups);
|
||||
|
||||
// Assert — wrapped default output
|
||||
List<ChatMessage> included = [.. groups.GetIncludedMessages()];
|
||||
Assert.Equal("CUSTOM_PREFIX\n[Tool Calls]\nfn:\n - result", included[1].Text);
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -16,7 +16,6 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
|
||||
|
||||
+81
-2
@@ -651,7 +651,7 @@ public class ExecutorRouteGeneratorTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartialClass_SendsYieldsInBothFiles_GeneratesAlOverrides()
|
||||
public void PartialClass_SendsYieldsInBothFiles_GeneratesAllOverrides()
|
||||
{
|
||||
// File 1: Partial with one handler
|
||||
var file1 = """
|
||||
@@ -700,7 +700,7 @@ public class ExecutorRouteGeneratorTests
|
||||
generated.Should().RegisterSentMessageType("string")
|
||||
.And.RegisterSentMessageType("int")
|
||||
.And.RegisterYieldedOutputType("string")
|
||||
.And.RegisterYieldedOutputType("string");
|
||||
.And.RegisterYieldedOutputType("int");
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1046,6 +1046,85 @@ public class ExecutorRouteGeneratorTests
|
||||
.And.RegisterSentMessageType("global::TestNamespace.BroadcastMessage");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtocolOnly_DerivesFromExecutorOfT_GeneratesBaseCall()
|
||||
{
|
||||
// A protocol-only partial executor deriving from Executor<T>
|
||||
// has a base class that already overrides ConfigureProtocol. The generator must emit
|
||||
// "return base.ConfigureProtocol(protocolBuilder)" so inherited handler registrations
|
||||
// are preserved — not "return protocolBuilder" which silently drops them.
|
||||
var source = """
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace TestNamespace;
|
||||
|
||||
public class FeedbackResult { }
|
||||
|
||||
[SendsMessage(typeof(FeedbackResult))]
|
||||
[YieldsOutput(typeof(string))]
|
||||
public partial class FeedbackExecutor : Executor<string>
|
||||
{
|
||||
public FeedbackExecutor() : base("feedback") { }
|
||||
|
||||
public override System.Threading.Tasks.ValueTask HandleAsync(string message, IWorkflowContext context, System.Threading.CancellationToken cancellationToken = default)
|
||||
=> default;
|
||||
}
|
||||
""";
|
||||
|
||||
var result = GeneratorTestHelper.RunGenerator(source);
|
||||
|
||||
result.RunResult.GeneratedTrees.Should().HaveCount(1);
|
||||
result.RunResult.Diagnostics.Should().BeEmpty();
|
||||
|
||||
var generated = result.RunResult.GeneratedTrees[0].ToString();
|
||||
|
||||
// Base class Executor<T> overrides ConfigureProtocol, so the generated override
|
||||
// must chain to base to preserve the inherited handler registration.
|
||||
generated.Should().Contain("return base.ConfigureProtocol(protocolBuilder)",
|
||||
because: "Executor<T> overrides ConfigureProtocol, so base must be called to preserve its handler registration");
|
||||
generated.Should().Contain(".SendsMessage<global::TestNamespace.FeedbackResult>()");
|
||||
generated.Should().Contain(".YieldsOutput<string>()");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtocolOnly_DerivesDirectlyFromExecutor_DoesNotGenerateBaseCall()
|
||||
{
|
||||
// A protocol-only partial executor deriving directly from Executor (abstract base
|
||||
// with no non-abstract ConfigureProtocol override) should generate "return protocolBuilder"
|
||||
// rather than "return base.ConfigureProtocol(protocolBuilder)".
|
||||
var source = """
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace TestNamespace;
|
||||
|
||||
public class BroadcastMessage { }
|
||||
|
||||
[SendsMessage(typeof(BroadcastMessage))]
|
||||
public partial class BroadcastExecutor : Executor
|
||||
{
|
||||
public BroadcastExecutor() : base("broadcast") { }
|
||||
}
|
||||
""";
|
||||
|
||||
var result = GeneratorTestHelper.RunGenerator(source);
|
||||
|
||||
result.RunResult.GeneratedTrees.Should().HaveCount(1);
|
||||
result.RunResult.Diagnostics.Should().BeEmpty();
|
||||
|
||||
var generated = result.RunResult.GeneratedTrees[0].ToString();
|
||||
|
||||
// Executor's ConfigureProtocol is abstract — no base call needed.
|
||||
generated.Should().Contain("return protocolBuilder",
|
||||
because: "Executor base class has no non-abstract ConfigureProtocol, so no base call is needed");
|
||||
generated.Should().NotContain("base.ConfigureProtocol");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Generic Executor Tests
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
@@ -199,4 +200,43 @@ public class EdgeRunnerTests
|
||||
mapping.CheckDeliveries(["executor3"], ["part1", "part2", "final part"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Test_FanInEdgeRunner_ConcurrentProcessingAsync()
|
||||
{
|
||||
// Arrange
|
||||
const int SourceCount = 4;
|
||||
const int Iterations = 50;
|
||||
|
||||
string[] sourceIds = Enumerable.Range(0, SourceCount).Select(i => $"source{i}").ToArray();
|
||||
const string SinkId = "sink";
|
||||
|
||||
TestRunContext runContext = new();
|
||||
List<Executor> executors = [.. sourceIds.Select(id => (Executor)new ForwardMessageExecutor<string>(id)), new ForwardMessageExecutor<string>(SinkId)];
|
||||
runContext.ConfigureExecutors(executors);
|
||||
|
||||
FanInEdgeData edgeData = new(sourceIds.ToList(), SinkId, new EdgeId(0), null);
|
||||
FanInEdgeRunner runner = new(runContext, edgeData);
|
||||
|
||||
for (int iteration = 0; iteration < Iterations; iteration++)
|
||||
{
|
||||
// Act: send messages from all sources concurrently
|
||||
using Barrier barrier = new(SourceCount);
|
||||
Task<DeliveryMapping?>[] tasks = sourceIds.Select(sourceId => Task.Run(async () =>
|
||||
{
|
||||
barrier.SignalAndWait();
|
||||
return await runner.ChaseEdgeAsync(new($"msg-from-{sourceId}", sourceId), stepTracer: null, CancellationToken.None);
|
||||
})).ToArray();
|
||||
|
||||
DeliveryMapping?[] results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert: exactly one task should return a non-null mapping with all messages
|
||||
DeliveryMapping?[] nonNullResults = results.Where(r => r is not null).ToArray();
|
||||
nonNullResults.Should().HaveCount(1, $"iteration {iteration}: exactly one thread should release the batch");
|
||||
|
||||
DeliveryMapping mapping = nonNullResults[0]!;
|
||||
HashSet<object> expectedMessages = [.. sourceIds.Select(id => (object)$"msg-from-{id}")];
|
||||
mapping.CheckDeliveries([SinkId], expectedMessages);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-16
@@ -13,26 +13,34 @@ description: >
|
||||
All commands run from the `python/` directory:
|
||||
|
||||
```bash
|
||||
# Format code (ruff format, parallel across packages)
|
||||
uv run poe fmt
|
||||
|
||||
# Lint and auto-fix (ruff check, parallel across packages)
|
||||
uv run poe lint
|
||||
# Syntax formatting + checks (parallel across packages by default)
|
||||
uv run poe syntax
|
||||
uv run poe syntax -P core
|
||||
uv run poe syntax -F # Format only
|
||||
uv run poe syntax -C # Check only
|
||||
uv run poe syntax -S # Samples only
|
||||
|
||||
# Type checking
|
||||
uv run poe pyright # Pyright (parallel across packages)
|
||||
uv run poe mypy # MyPy (parallel across packages)
|
||||
uv run poe pyright # Pyright fan-out across packages
|
||||
uv run poe pyright -P core
|
||||
uv run poe pyright -A
|
||||
uv run poe mypy # MyPy fan-out across packages
|
||||
uv run poe mypy -P core
|
||||
uv run poe mypy -A
|
||||
uv run poe typing # Both pyright and mypy
|
||||
uv run poe typing -P core
|
||||
uv run poe typing -A
|
||||
|
||||
# All package-level checks in parallel (fmt + lint + pyright + mypy)
|
||||
# All package-level checks in parallel (syntax + pyright)
|
||||
uv run poe check-packages
|
||||
|
||||
# Full check (packages + samples + tests + markdown)
|
||||
uv run poe check
|
||||
uv run poe check -P core
|
||||
|
||||
# Samples only
|
||||
uv run poe samples-lint # Ruff lint on samples/
|
||||
uv run poe samples-syntax # Pyright syntax check on samples/
|
||||
uv run poe check -S
|
||||
uv run poe pyright -S
|
||||
|
||||
# Markdown code blocks
|
||||
uv run poe markdown-code-lint
|
||||
@@ -40,8 +48,8 @@ uv run poe markdown-code-lint
|
||||
|
||||
## Pre-commit Hooks (prek)
|
||||
|
||||
Prek hooks run automatically on commit. They check only changed files and run
|
||||
package-level checks in parallel for affected packages only.
|
||||
Prek hooks run automatically on commit. They stay lightweight and only check
|
||||
changed files.
|
||||
|
||||
```bash
|
||||
# Install hooks
|
||||
@@ -54,8 +62,10 @@ uv run prek run -a
|
||||
uv run prek run --last-commit
|
||||
```
|
||||
|
||||
When core package changes, type-checking (mypy, pyright) runs across all packages
|
||||
since type changes propagate. Format and lint only run in changed packages.
|
||||
They run changed-package syntax formatting/checking, markdown code lint only
|
||||
when markdown files change, and sample syntax lint/pyright only when files
|
||||
under `samples/` change.
|
||||
They intentionally do not run workspace `pyright` or `mypy` by default.
|
||||
|
||||
## Ruff Configuration
|
||||
|
||||
@@ -80,6 +90,6 @@ in-process with streaming output.
|
||||
|
||||
CI splits into 4 parallel jobs:
|
||||
1. **Pre-commit hooks** — lightweight hooks (SKIP=poe-check)
|
||||
2. **Package checks** — fmt/lint/pyright via check-packages
|
||||
3. **Samples & markdown** — samples-lint, samples-syntax, markdown-code-lint
|
||||
2. **Package checks** — syntax/pyright via check-packages
|
||||
3. **Samples & markdown** — `check -S` plus `markdown-code-lint`
|
||||
4. **Mypy** — change-detected mypy checks
|
||||
|
||||
@@ -47,17 +47,17 @@ uv run poe upgrade-dev-dependencies
|
||||
|
||||
# First, run workspace-wide lower/upper compatibility gates
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --project "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test --project <workspace-package-name>
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
|
||||
# Then expand bounds for one dependency in the target package
|
||||
uv run poe validate-dependency-bounds-project --mode both --project <workspace-package-name> --dependency "<dependency-name>"
|
||||
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
|
||||
|
||||
# Repo-wide automation can reuse the same task
|
||||
uv run poe validate-dependency-bounds-project --mode upper --project "*"
|
||||
uv run poe validate-dependency-bounds-project --mode upper --package "*"
|
||||
|
||||
# Add a dependency to one project and run both validators for that project/dependency
|
||||
uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"
|
||||
uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"
|
||||
```
|
||||
|
||||
### Dependency Bound Notes
|
||||
@@ -66,7 +66,7 @@ uv run poe add-dependency-and-validate-bounds --project <workspace-package-name>
|
||||
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges).
|
||||
- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible.
|
||||
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
|
||||
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--project "*"` and omitting `--dependency`.
|
||||
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
|
||||
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
|
||||
- Use `upgrade-dev-dependencies` for repo-wide dev tooling refreshes; it repins dev dependencies, refreshes `uv.lock`, and reruns `check`, `typing`, and `test`.
|
||||
@@ -108,12 +108,12 @@ def __getattr__(name: str) -> Any:
|
||||
Recommended dependency workflow during connector implementation:
|
||||
|
||||
1. Add the dependency to the target package:
|
||||
`uv run poe add-dependency-to-project --project <workspace-package-name> --dependency "<dependency-spec>"`
|
||||
`uv run poe add-dependency-to-project --package core --dependency "<dependency-spec>"`
|
||||
2. Implement connector code and tests.
|
||||
3. Validate dependency bounds for that package/dependency:
|
||||
`uv run poe validate-dependency-bounds-project --mode both --project <workspace-package-name> --dependency "<dependency-name>"`
|
||||
`uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"`
|
||||
4. If the package has meaningful tests/checks that validate dependency compatibility, you can use the add + validation flow in one command:
|
||||
`uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"`
|
||||
`uv run poe add-dependency-and-validate-bounds --package core --dependency "<dependency-spec>"`
|
||||
If compatibility checks are not in place yet, add the dependency first, then implement tests before running bound validation.
|
||||
|
||||
### Promotion to Stable
|
||||
|
||||
+7
-4
@@ -41,11 +41,14 @@ Do **not** add sample-only dependencies to the root `pyproject.toml` dev group.
|
||||
## Syntax Checking
|
||||
|
||||
```bash
|
||||
# Check samples for syntax errors and missing imports
|
||||
uv run poe samples-syntax
|
||||
# Format + lint samples
|
||||
uv run poe syntax -S
|
||||
|
||||
# Lint samples
|
||||
uv run poe samples-lint
|
||||
# Check samples for syntax errors and missing imports
|
||||
uv run poe pyright -S
|
||||
|
||||
# Lint samples only
|
||||
uv run poe syntax -S -C
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
+15
-8
@@ -17,20 +17,27 @@ We run tests in two stages, for a PR each commit is tested with unit tests only
|
||||
# Run tests for all packages in parallel
|
||||
uv run poe test
|
||||
|
||||
# Run tests for a specific package
|
||||
uv run --directory packages/core poe test
|
||||
# Run tests for a specific workspace package
|
||||
uv run poe test -P core
|
||||
|
||||
# Run all tests in a single pytest invocation (faster, uses pytest-xdist)
|
||||
uv run poe all-tests
|
||||
# Run all selected tests in a single pytest invocation
|
||||
uv run poe test -A
|
||||
|
||||
# With coverage
|
||||
uv run poe all-tests-cov
|
||||
uv run poe test -A -C
|
||||
uv run poe test -P core -C
|
||||
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
uv run poe test -A -m "not integration"
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
uv run poe test -A -m integration
|
||||
```
|
||||
|
||||
Direct package execution still works when you need it:
|
||||
|
||||
```bash
|
||||
uv run --directory packages/core poe test
|
||||
```
|
||||
|
||||
## Test Configuration
|
||||
@@ -38,7 +45,7 @@ uv run poe all-tests -m integration
|
||||
- **Async mode**: `asyncio_mode = "auto"` is enabled — do NOT use `@pytest.mark.asyncio`, but do mark tests with `async def` and use `await` for async calls
|
||||
- **Timeout**: Default 60 seconds per test
|
||||
- **Import mode**: `importlib` for cross-package isolation
|
||||
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The `all-tests` task also uses xdist across all packages.
|
||||
- **Parallelization**: Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` (`-n auto --dist worksteal`) in their `poe test` task. The aggregate `uv run poe test -A` sweep also uses xdist across the selected packages.
|
||||
|
||||
## Test Directory Structure
|
||||
|
||||
|
||||
@@ -52,10 +52,10 @@ repos:
|
||||
hooks:
|
||||
- id: poe-check
|
||||
name: Run checks through Poe
|
||||
entry: uv run poe prek-check
|
||||
entry: uv run python scripts/workspace_poe_tasks.py prek-check
|
||||
language: system
|
||||
- repo: https://github.com/PyCQA/bandit
|
||||
rev: 1.9.3
|
||||
rev: 1.9.4
|
||||
hooks:
|
||||
- id: bandit
|
||||
name: Bandit Security Checks
|
||||
@@ -63,7 +63,7 @@ repos:
|
||||
additional_dependencies: ["bandit[toml]"]
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
# uv version.
|
||||
rev: 0.10.0
|
||||
rev: 0.10.10
|
||||
hooks:
|
||||
# Update the uv lockfile
|
||||
- id: uv-lock
|
||||
|
||||
Vendored
+49
-12
@@ -9,9 +9,8 @@
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"prek",
|
||||
"run",
|
||||
"-a"
|
||||
"poe",
|
||||
"check"
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -32,13 +31,13 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Format",
|
||||
"label": "Syntax",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"fmt",
|
||||
"syntax",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -59,13 +58,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Lint",
|
||||
"label": "Syntax (format only)",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"lint",
|
||||
"syntax",
|
||||
"-F",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
"fileLocation": [
|
||||
"relative",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"pattern": {
|
||||
"regexp": "^(.*):(\\d+):(\\d+):\\s+(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3,
|
||||
"message": 4
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"panel": "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Syntax (check only)",
|
||||
"type": "shell",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"syntax",
|
||||
"-C",
|
||||
],
|
||||
"problemMatcher": {
|
||||
"owner": "python",
|
||||
@@ -169,7 +197,14 @@
|
||||
{
|
||||
"label": "Create Venv",
|
||||
"type": "shell",
|
||||
"command": "uv venv PYTHON=${input:py_version}",
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"run",
|
||||
"poe",
|
||||
"venv",
|
||||
"-P",
|
||||
"${input:py_version}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
@@ -184,7 +219,8 @@
|
||||
"run",
|
||||
"poe",
|
||||
"setup",
|
||||
"--python=${input:py_version}"
|
||||
"-P",
|
||||
"${input:py_version}"
|
||||
],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
@@ -200,11 +236,12 @@
|
||||
"3.10",
|
||||
"3.11",
|
||||
"3.12",
|
||||
"3.13"
|
||||
"3.13",
|
||||
"3.14"
|
||||
],
|
||||
"id": "py_version",
|
||||
"description": "Python version",
|
||||
"default": "3.10"
|
||||
"default": "3.13"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+51
-1
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.0rc5] - 2026-03-19
|
||||
|
||||
### Added
|
||||
|
||||
- **samples**: Add foundry hosted agents samples for python ([#4648](https://github.com/microsoft/agent-framework/pull/4648))
|
||||
- **repo**: Add automated stale issue and PR follow-up ping workflow ([#4776](https://github.com/microsoft/agent-framework/pull/4776))
|
||||
- **agent-framework-ag-ui**: Emit AG-UI events for MCP tool calls, results, and text reasoning ([#4760](https://github.com/microsoft/agent-framework/pull/4760))
|
||||
- **agent-framework-ag-ui**: Emit TOOL_CALL_RESULT events when resuming after tool approval ([#4758](https://github.com/microsoft/agent-framework/pull/4758))
|
||||
|
||||
### Changed
|
||||
|
||||
- **agent-framework-devui**: Bump minimatch from 3.1.2 to 3.1.5 in frontend ([#4337](https://github.com/microsoft/agent-framework/pull/4337))
|
||||
- **agent-framework-devui**: Bump rollup from 4.47.1 to 4.59.0 in frontend ([#4338](https://github.com/microsoft/agent-framework/pull/4338))
|
||||
- **agent-framework-core**: Unify tool results as `Content` items with rich content support ([#4331](https://github.com/microsoft/agent-framework/pull/4331))
|
||||
- **agent-framework-a2a**: Default `A2AAgent` name and description from `AgentCard` ([#4661](https://github.com/microsoft/agent-framework/pull/4661))
|
||||
- **agent-framework-core**: [BREAKING] Clean up kwargs across agents, chat clients, tools, and sessions ([#4581](https://github.com/microsoft/agent-framework/pull/4581))
|
||||
- **agent-framework-devui**: Bump tar from 7.5.9 to 7.5.11 ([#4688](https://github.com/microsoft/agent-framework/pull/4688))
|
||||
- **repo**: Improve Python dependency range automation ([#4343](https://github.com/microsoft/agent-framework/pull/4343))
|
||||
- **agent-framework-core**: Normalize empty MCP tool output to `null` ([#4683](https://github.com/microsoft/agent-framework/pull/4683))
|
||||
- **agent-framework-core**: Remove bad dependency ([#4696](https://github.com/microsoft/agent-framework/pull/4696))
|
||||
- **agent-framework-core**: Keep MCP cleanup on the owner task ([#4687](https://github.com/microsoft/agent-framework/pull/4687))
|
||||
- **agent-framework-a2a**: Preserve A2A message `context_id` ([#4686](https://github.com/microsoft/agent-framework/pull/4686))
|
||||
- **repo**: Bump `danielpalme/ReportGenerator-GitHub-Action` from 5.5.1 to 5.5.3 ([#4542](https://github.com/microsoft/agent-framework/pull/4542))
|
||||
- **repo**: Bump `MishaKav/pytest-coverage-comment` from 1.2.0 to 1.6.0 ([#4543](https://github.com/microsoft/agent-framework/pull/4543))
|
||||
- **agent-framework-core**: Bump `pyjwt` from 2.11.0 to 2.12.0 ([#4699](https://github.com/microsoft/agent-framework/pull/4699))
|
||||
- **agent-framework-azure-ai**: Reduce Azure chat client import overhead ([#4744](https://github.com/microsoft/agent-framework/pull/4744))
|
||||
- **repo**: Simplify Python Poe tasks and unify package selectors ([#4722](https://github.com/microsoft/agent-framework/pull/4722))
|
||||
- **agent-framework-core**: Aggregate token usage across tool-call loop iterations in `invoke_agent` span ([#4739](https://github.com/microsoft/agent-framework/pull/4739))
|
||||
- **agent-framework-core**: Support `detail` field in OpenAI Chat API `image_url` payload ([#4756](https://github.com/microsoft/agent-framework/pull/4756))
|
||||
- **agent-framework-anthropic**: [BREAKING] Refactor middleware layering and split Anthropic raw client ([#4746](https://github.com/microsoft/agent-framework/pull/4746))
|
||||
- **agent-framework-github-copilot**: Emit tool call events in GitHubCopilotAgent streaming ([4711](https://github.com/microsoft/agent-framework/pull/4711))
|
||||
|
||||
### Fixed
|
||||
|
||||
- **agent-framework-core**: Validate approval responses against the server-side pending request registry ([#4548](https://github.com/microsoft/agent-framework/pull/4548))
|
||||
- **agent-framework-devui**: Validate function approval responses in the DevUI executor ([#4598](https://github.com/microsoft/agent-framework/pull/4598))
|
||||
- **agent-framework-azurefunctions**: Use `deepcopy` for state snapshots so nested mutations are detected in durable workflow activities ([#4518](https://github.com/microsoft/agent-framework/pull/4518))
|
||||
- **agent-framework-bedrock**: Fix `BedrockChatClient` sending invalid toolChoice `"none"` to the Bedrock API ([#4535](https://github.com/microsoft/agent-framework/pull/4535))
|
||||
- **agent-framework-core**: Fix type hint for `Case` and `Default` ([#3985](https://github.com/microsoft/agent-framework/pull/3985))
|
||||
- **agent-framework-core**: Fix duplicate tool names between supplied tools and MCP servers ([#4649](https://github.com/microsoft/agent-framework/pull/4649))
|
||||
- **agent-framework-core**: Fix `_deduplicate_messages` catch-all branch dropping valid repeated messages ([#4716](https://github.com/microsoft/agent-framework/pull/4716))
|
||||
- **samples**: Fix Azure Redis sample missing session for history persistence ([#4692](https://github.com/microsoft/agent-framework/pull/4692))
|
||||
- **agent-framework-core**: Fix thread serialization for multi-turn tool calls ([#4684](https://github.com/microsoft/agent-framework/pull/4684))
|
||||
- **agent-framework-core**: Fix `RUN_FINISHED.interrupt` to accumulate all interrupts when multiple tools need approval ([#4717](https://github.com/microsoft/agent-framework/pull/4717))
|
||||
- **agent-framework-azurefunctions**: Fix missing methods on the `Content` class in durable tasks ([#4738](https://github.com/microsoft/agent-framework/pull/4738))
|
||||
- **agent-framework-core**: Fix `ENABLE_SENSITIVE_DATA` being ignored when set after module import ([#4743](https://github.com/microsoft/agent-framework/pull/4743))
|
||||
- **agent-framework-a2a**: Fix `A2AAgent` to invoke context providers before and after run ([#4757](https://github.com/microsoft/agent-framework/pull/4757))
|
||||
- **agent-framework-core**: Fix MCP tool schema normalization for zero-argument tools missing the `properties` key ([#4771](https://github.com/microsoft/agent-framework/pull/4771))
|
||||
|
||||
## [1.0.0rc4] - 2026-03-11
|
||||
|
||||
### Added
|
||||
@@ -768,7 +817,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...HEAD
|
||||
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
|
||||
[1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4
|
||||
[1.0.0rc3]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...python-1.0.0rc3
|
||||
[1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2
|
||||
|
||||
@@ -403,7 +403,7 @@ So we use bounded ranges for external package dependencies in `pyproject.toml`:
|
||||
- For `<1.0.0` dependencies, use a known-good bounded range with an explicit upper cap. Prefer the broadest validated range the package can actually support: that may be a patch line, a minor line, or multiple minor lines (for example: `a2a-sdk>=0.3.5,<0.4.0`, `fastapi>=0.115.0,<0.136.0`, `uvicorn>=0.30.0,<0.39.0`).
|
||||
- For prerelease (`dev`/`a`/`b`/`rc`) dependencies, use a known-good bounded range with a hard upper cap and keep the range only as broad as the package's validation coverage justifies.
|
||||
- Prefer keeping support for multiple major versions when practical. This may mean that the upper bound spans multiple major versions when the dependency maintains backward compatibility; if APIs differ between supported majors, version-conditional imports/branches are acceptable to preserve compatibility.
|
||||
- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --project <workspace-package-name> --dependency "<dependency-name>"` to expand package-scoped bounds.
|
||||
- When adding or changing an external dependency, first run `uv run poe validate-dependency-bounds-test` to validate workspace-wide lower/upper compatibility, then run `uv run poe validate-dependency-bounds-project --mode both --package <workspace-package-name> --dependency "<dependency-name>"` to expand package-scoped bounds.
|
||||
|
||||
### Installation Options
|
||||
|
||||
|
||||
+129
-86
@@ -123,28 +123,39 @@ client = OpenAIChatClient(env_file_path="openai.env")
|
||||
|
||||
All the tests are located in the `tests` folder of each package. Tests marked with `@pytest.mark.integration` and `@skip_if_..._integration_tests_disabled` are integration tests that require external services (e.g., OpenAI, Azure OpenAI). They are automatically skipped when the required API keys or service endpoints are not configured in your environment or `.env` file.
|
||||
|
||||
You can select or exclude integration tests using pytest markers:
|
||||
The root `test` command now supports both project-scoped fan-out and a single aggregate sweep:
|
||||
|
||||
```bash
|
||||
# Run only unit tests (exclude integration tests)
|
||||
uv run poe all-tests -m "not integration"
|
||||
# Run package-local tests across all workspace packages
|
||||
uv run poe test
|
||||
|
||||
# Run only integration tests
|
||||
uv run poe all-tests -m integration
|
||||
# Run tests for one workspace package
|
||||
uv run poe test -P core
|
||||
|
||||
# Run an aggregate pytest sweep across the selected packages
|
||||
uv run poe test -A
|
||||
|
||||
# Run only unit tests in aggregate mode
|
||||
uv run poe test -A -m "not integration"
|
||||
|
||||
# Run only integration tests in aggregate mode
|
||||
uv run poe test -A -m integration
|
||||
|
||||
# Run tests with coverage for one package or an aggregate sweep
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
Alternatively, you can run them using VSCode Tasks. Open the command palette
|
||||
(`Ctrl+Shift+P`) and type `Tasks: Run Task`. Select `Test` from the list.
|
||||
|
||||
If you want to run the tests for a single package, you can use the `uv run poe test` command with the package name as an argument. For example, to run the tests for the `agent_framework` package, you can use:
|
||||
Direct package execution still works when you need it:
|
||||
|
||||
```bash
|
||||
uv run poe --directory packages/core test
|
||||
```
|
||||
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The `all-tests` task also uses xdist across all packages.
|
||||
|
||||
These commands also output the coverage report.
|
||||
Large packages (core, ag-ui, orchestrations, anthropic) use `pytest-xdist` for parallel test execution within the package. The aggregate `test -A` sweep also uses `pytest-xdist` across the selected packages.
|
||||
|
||||
## Code quality checks
|
||||
|
||||
@@ -158,10 +169,11 @@ Ideally you should run these checks before committing any changes, when you inst
|
||||
|
||||
## Code Coverage
|
||||
|
||||
We try to maintain a high code coverage for the project. To run the code coverage on the unit tests, you can use the following command:
|
||||
We try to maintain a high code coverage for the project. To review coverage locally, use either a package-scoped run or the aggregate sweep:
|
||||
|
||||
```bash
|
||||
uv run poe test
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
This will show you which files are not covered by the tests, including the specific lines not covered. Make sure to consider the untested lines from the code you are working on, but feel free to add other tests as well, that is always welcome!
|
||||
@@ -213,7 +225,7 @@ Set up the development environment with a virtual environment, install dependenc
|
||||
```bash
|
||||
uv run poe setup
|
||||
# or with specific Python version
|
||||
uv run poe setup --python 3.12
|
||||
uv run poe setup -P 3.12
|
||||
```
|
||||
|
||||
#### `install`
|
||||
@@ -230,7 +242,7 @@ Create a virtual environment with specified Python version or switch python vers
|
||||
```bash
|
||||
uv run poe venv
|
||||
# or with specific Python version
|
||||
uv run poe venv --python 3.12
|
||||
uv run poe venv -P 3.12
|
||||
```
|
||||
|
||||
#### `prek-install`
|
||||
@@ -239,41 +251,89 @@ Install prek hooks:
|
||||
uv run poe prek-install
|
||||
```
|
||||
|
||||
### Code Quality and Formatting
|
||||
### Project-scoped command families
|
||||
|
||||
Each of the following tasks run against both the main `agent-framework` package and the extension packages in parallel, ensuring consistent code quality across the project.
|
||||
These commands default to `--package "*"`, so they run across all workspace packages unless you narrow them with `-P/--package`:
|
||||
|
||||
#### `fmt` (format)
|
||||
Format code using ruff (runs in parallel across all packages):
|
||||
#### `syntax`
|
||||
Run Ruff formatting plus Ruff lint checks by default:
|
||||
```bash
|
||||
uv run poe fmt
|
||||
uv run poe syntax
|
||||
uv run poe syntax -P core
|
||||
uv run poe syntax -F # format only
|
||||
uv run poe syntax -C # lint/check only
|
||||
```
|
||||
|
||||
#### `lint`
|
||||
Run linting checks and fix issues (runs in parallel across all packages):
|
||||
#### `build`
|
||||
Build workspace packages and the root meta package:
|
||||
```bash
|
||||
uv run poe lint
|
||||
uv run poe build
|
||||
uv run poe build -P core
|
||||
```
|
||||
|
||||
#### `clean-dist`
|
||||
Clean generated dist artifacts:
|
||||
```bash
|
||||
uv run poe clean-dist
|
||||
uv run poe clean-dist -P core
|
||||
```
|
||||
|
||||
### Dual-mode validation and test commands
|
||||
|
||||
These command families share the same selector model:
|
||||
|
||||
```bash
|
||||
uv run poe <command> # project fan-out over --package "*"
|
||||
uv run poe <command> -P core # one-project fan-out
|
||||
uv run poe <command> -A # aggregate sweep where supported
|
||||
```
|
||||
|
||||
#### `pyright`
|
||||
Run Pyright type checking (runs in parallel across all packages):
|
||||
Run Pyright type checking:
|
||||
```bash
|
||||
uv run poe pyright
|
||||
uv run poe pyright -P core
|
||||
uv run poe pyright -A
|
||||
```
|
||||
|
||||
#### `mypy`
|
||||
Run MyPy type checking (runs in parallel across all packages):
|
||||
Run MyPy type checking:
|
||||
```bash
|
||||
uv run poe mypy
|
||||
uv run poe mypy -P core
|
||||
uv run poe mypy -A
|
||||
```
|
||||
|
||||
#### `typing`
|
||||
Run both Pyright and MyPy type checking:
|
||||
Run both Pyright and MyPy:
|
||||
```bash
|
||||
uv run poe typing
|
||||
uv run poe typing -P core
|
||||
uv run poe typing -A
|
||||
```
|
||||
|
||||
### Code Validation
|
||||
#### `test`
|
||||
Run package-local tests in fan-out mode, or switch to one aggregate pytest sweep with `-A`:
|
||||
```bash
|
||||
uv run poe test
|
||||
uv run poe test -P core
|
||||
uv run poe test -P core -C
|
||||
uv run poe test -A
|
||||
uv run poe test -A -C
|
||||
```
|
||||
|
||||
### Sample-target variants
|
||||
|
||||
Use `-S/--samples` for sample-only validation instead of separate top-level commands:
|
||||
|
||||
```bash
|
||||
uv run poe syntax -S
|
||||
uv run poe syntax -S -C
|
||||
uv run poe pyright -S
|
||||
uv run poe check -S
|
||||
```
|
||||
|
||||
### Workspace validation and dependency commands
|
||||
|
||||
#### `markdown-code-lint`
|
||||
Lint markdown code blocks:
|
||||
@@ -281,26 +341,41 @@ Lint markdown code blocks:
|
||||
uv run poe markdown-code-lint
|
||||
```
|
||||
|
||||
#### `check-packages`
|
||||
Run the package-level syntax sweep (`syntax`) plus `pyright` across the selected projects:
|
||||
```bash
|
||||
uv run poe check-packages
|
||||
uv run poe check-packages -P core
|
||||
```
|
||||
|
||||
#### `check`
|
||||
Run package syntax, pyright, and tests for the selected project set. Without `-P/--package`, it also includes sample checks and markdown lint:
|
||||
```bash
|
||||
uv run poe check
|
||||
uv run poe check -P core
|
||||
uv run poe check -S
|
||||
```
|
||||
|
||||
#### `validate-dependency-bounds-test`
|
||||
Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure:
|
||||
```bash
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --project "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test --project <workspace-package-name>
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
uv run poe validate-dependency-bounds-test -P core
|
||||
```
|
||||
|
||||
#### `validate-dependency-bounds-project`
|
||||
Validate and extend dependency bounds for a single dependency in a single package. Use `--mode lower`, `--mode upper`, or the default `--mode both`:
|
||||
```bash
|
||||
uv run poe validate-dependency-bounds-project --mode both --project <workspace-package-name> --dependency "<dependency-name>"
|
||||
uv run poe validate-dependency-bounds-project -M both -P core -D "<dependency-name>"
|
||||
```
|
||||
`--project` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --project "*"` to run the upper-bound pass across the workspace.
|
||||
`--package` defaults to `*`, and `--dependency` is optional. Automation can use `--mode upper --package "*"` to run the upper-bound pass across the workspace.
|
||||
For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may still be a single patch or minor line, but multi-minor ranges are fine when the package's checks/tests prove they work.
|
||||
|
||||
#### `add-dependency-and-validate-bounds`
|
||||
Add an external dependency to a workspace project and run both validators for that same project/dependency:
|
||||
```bash
|
||||
uv run poe add-dependency-and-validate-bounds --project <workspace-package-name> --dependency "<dependency-spec>"
|
||||
uv run poe add-dependency-and-validate-bounds -P core -D "<dependency-spec>"
|
||||
```
|
||||
|
||||
#### `upgrade-dev-dependencies`
|
||||
@@ -310,72 +385,40 @@ uv run poe upgrade-dev-dependencies
|
||||
```
|
||||
Use this for repo-wide dev tooling refreshes. For targeted runtime dependency upgrades, prefer `uv lock --upgrade-package <dependency-name>` plus the package-scoped bound validation tasks above.
|
||||
|
||||
### Comprehensive Checks
|
||||
|
||||
#### `check-packages`
|
||||
Run all package-level quality checks (format, lint, pyright, mypy) in parallel across all packages. This runs the full cross-product of (package Ă— check) concurrently:
|
||||
```bash
|
||||
uv run poe check-packages
|
||||
```
|
||||
|
||||
#### `check`
|
||||
Run all quality checks including package checks, samples, tests and markdown lint:
|
||||
```bash
|
||||
uv run poe check
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
#### `test`
|
||||
Run unit tests with coverage by invoking the `test` task in each package in parallel:
|
||||
```bash
|
||||
uv run poe test
|
||||
```
|
||||
|
||||
To run tests for a specific package only, use the `--directory` flag:
|
||||
```bash
|
||||
# Run tests for the core package
|
||||
uv run --directory packages/core poe test
|
||||
|
||||
# Run tests for the azure-ai package
|
||||
uv run --directory packages/azure-ai poe test
|
||||
```
|
||||
|
||||
#### `all-tests`
|
||||
Run all tests in a single pytest invocation across all packages in parallel (excluding lab and devui). This is faster than `test` as it uses pytest's parallel execution:
|
||||
```bash
|
||||
uv run poe all-tests
|
||||
```
|
||||
|
||||
#### `all-tests-cov`
|
||||
Same as `all-tests` but with coverage reporting enabled:
|
||||
```bash
|
||||
uv run poe all-tests-cov
|
||||
```
|
||||
|
||||
### Building and Publishing
|
||||
|
||||
#### `build`
|
||||
Build all packages:
|
||||
```bash
|
||||
uv run poe build
|
||||
```
|
||||
|
||||
#### `clean-dist`
|
||||
Clean the dist directories:
|
||||
```bash
|
||||
uv run poe clean-dist
|
||||
```
|
||||
|
||||
#### `publish`
|
||||
Publish packages to PyPI:
|
||||
```bash
|
||||
uv run poe publish
|
||||
```
|
||||
|
||||
### Compatibility aliases
|
||||
|
||||
These legacy commands still work during the transition, but prefer the newer forms above:
|
||||
|
||||
```bash
|
||||
uv run poe fmt # prefer: uv run poe syntax -F
|
||||
uv run poe format # prefer: uv run poe syntax -F
|
||||
uv run poe lint # prefer: uv run poe syntax -C
|
||||
uv run poe all-tests # prefer: uv run poe test -A
|
||||
uv run poe all-tests-cov # prefer: uv run poe test -A -C
|
||||
uv run poe samples-lint # prefer: uv run poe syntax -S -C
|
||||
uv run poe samples-syntax # prefer: uv run poe pyright -S
|
||||
```
|
||||
|
||||
## Prek Hooks
|
||||
|
||||
Prek hooks run automatically on commit and execute a subset of the checks on changed files only. Package-level checks (fmt, lint, pyright) run in parallel but only for packages with changed files. Markdown and sample checks are skipped when no relevant files were changed. If the `core` package is changed, all packages are checked. You can also run all checks using prek directly:
|
||||
Prek hooks run automatically on commit and stay intentionally lightweight:
|
||||
|
||||
- changed-package syntax formatting
|
||||
- changed-package syntax lint/check
|
||||
- markdown code lint only when markdown files change
|
||||
- sample lint + sample pyright only when files under `samples/` change
|
||||
|
||||
They do **not** run workspace `pyright` or `mypy` by default. Use `uv run poe pyright`, `uv run poe mypy`, `uv run poe typing`, `uv run poe check-packages`, or `uv run poe check` when you want deeper validation.
|
||||
|
||||
You can run the installed hooks directly with:
|
||||
|
||||
```bash
|
||||
uv run prek run -a
|
||||
|
||||
@@ -35,10 +35,12 @@ from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseAgent,
|
||||
BaseHistoryProvider,
|
||||
Content,
|
||||
ContinuationToken,
|
||||
Message,
|
||||
ResponseStream,
|
||||
SessionContext,
|
||||
normalize_messages,
|
||||
prepend_agent_framework_to_user_agent,
|
||||
)
|
||||
@@ -284,17 +286,36 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
When stream=True: A ResponseStream of AgentResponseUpdate items.
|
||||
"""
|
||||
del function_invocation_kwargs, client_kwargs, kwargs
|
||||
normalized_messages = normalize_messages(messages)
|
||||
|
||||
if continuation_token is not None:
|
||||
a2a_stream: AsyncIterable[A2AStreamItem] = self.client.resubscribe(
|
||||
TaskIdParams(id=continuation_token["task_id"])
|
||||
)
|
||||
else:
|
||||
normalized_messages = normalize_messages(messages)
|
||||
if not normalized_messages:
|
||||
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
|
||||
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
|
||||
a2a_stream = self.client.send_message(a2a_message)
|
||||
|
||||
provider_session = session
|
||||
if provider_session is None and self.context_providers:
|
||||
provider_session = AgentSession()
|
||||
|
||||
session_context = SessionContext(
|
||||
session_id=provider_session.session_id if provider_session else None,
|
||||
service_session_id=provider_session.service_session_id if provider_session else None,
|
||||
input_messages=normalized_messages or [],
|
||||
options={},
|
||||
)
|
||||
|
||||
response = ResponseStream(
|
||||
self._map_a2a_stream(a2a_stream, background=background),
|
||||
self._map_a2a_stream(
|
||||
a2a_stream,
|
||||
background=background,
|
||||
session=provider_session,
|
||||
session_context=session_context,
|
||||
),
|
||||
finalizer=AgentResponse.from_updates,
|
||||
)
|
||||
if stream:
|
||||
@@ -306,6 +327,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
a2a_stream: AsyncIterable[A2AStreamItem],
|
||||
*,
|
||||
background: bool = False,
|
||||
session: AgentSession | None = None,
|
||||
session_context: SessionContext | None = None,
|
||||
) -> AsyncIterable[AgentResponseUpdate]:
|
||||
"""Map raw A2A protocol items to AgentResponseUpdates.
|
||||
|
||||
@@ -316,24 +339,52 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
background: When False, in-progress task updates are silently
|
||||
consumed (the stream keeps iterating until a terminal state).
|
||||
When True, they are yielded with a continuation token.
|
||||
session: The agent session for context providers.
|
||||
session_context: The session context for context providers.
|
||||
"""
|
||||
if session_context is None:
|
||||
session_context = SessionContext(input_messages=[], options={})
|
||||
|
||||
# Run before_run providers (forward order)
|
||||
for provider in self.context_providers:
|
||||
if isinstance(provider, BaseHistoryProvider) and not provider.load_messages:
|
||||
continue
|
||||
if session is None:
|
||||
raise RuntimeError("Provider session must be available when context providers are configured.")
|
||||
await provider.before_run(
|
||||
agent=self, # type: ignore[arg-type]
|
||||
session=session,
|
||||
context=session_context,
|
||||
state=session.state.setdefault(provider.source_id, {}),
|
||||
)
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
async for item in a2a_stream:
|
||||
if isinstance(item, A2AMessage):
|
||||
# Process A2A Message
|
||||
contents = self._parse_contents_from_a2a(item.parts)
|
||||
yield AgentResponseUpdate(
|
||||
update = AgentResponseUpdate(
|
||||
contents=contents,
|
||||
role="assistant" if item.role == A2ARole.agent else "user",
|
||||
response_id=str(getattr(item, "message_id", uuid.uuid4())),
|
||||
raw_representation=item,
|
||||
)
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[0], Task):
|
||||
task, _update_event = item
|
||||
for update in self._updates_from_task(task, background=background):
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
raise NotImplementedError("Only Message and Task responses are supported")
|
||||
|
||||
# Set the response on the context for after_run providers
|
||||
if all_updates:
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
await self._run_after_providers(session=session, context=session_context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Task helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_a2a"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_a2a --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -23,11 +23,14 @@ from a2a.types import Role as A2ARole
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentResponseUpdate,
|
||||
AgentSession,
|
||||
BaseContextProvider,
|
||||
Content,
|
||||
Message,
|
||||
SessionContext,
|
||||
)
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, raises
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a._agent import _get_uri_data # type: ignore
|
||||
@@ -851,3 +854,188 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Context Provider Tests
|
||||
|
||||
|
||||
class TrackingContextProvider(BaseContextProvider):
|
||||
"""A context provider that records when before_run and after_run are called."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(source_id="tracking-provider")
|
||||
self.before_run_called = False
|
||||
self.after_run_called = False
|
||||
self.before_run_context: SessionContext | None = None
|
||||
self.after_run_context: SessionContext | None = None
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
self.before_run_called = True
|
||||
self.before_run_context = context
|
||||
|
||||
async def after_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
self.after_run_called = True
|
||||
self.after_run_context = context
|
||||
|
||||
|
||||
async def test_run_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context providers are invoked during non-streaming run."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello from A2A")
|
||||
session = agent.create_session()
|
||||
|
||||
response = await agent.run("Hello", session=session)
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
assert response.text == "Hello from A2A"
|
||||
|
||||
|
||||
async def test_run_streaming_invokes_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context providers are invoked during streaming run."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Streamed response")
|
||||
session = agent.create_session()
|
||||
|
||||
stream = agent.run("Hello", stream=True, session=session)
|
||||
updates = []
|
||||
async for update in stream:
|
||||
updates.append(update)
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "Streamed response"
|
||||
|
||||
|
||||
async def test_context_providers_receive_response(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that after_run providers can access the response via session context."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Response text")
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert provider.after_run_context is not None
|
||||
assert provider.after_run_context.response is not None
|
||||
assert provider.after_run_context.response.text == "Response text"
|
||||
|
||||
|
||||
async def test_context_providers_receive_input_messages(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that before_run providers can access input messages via session context."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Reply")
|
||||
session = agent.create_session()
|
||||
|
||||
await agent.run("Hello world", session=session)
|
||||
|
||||
assert provider.before_run_context is not None
|
||||
assert len(provider.before_run_context.input_messages) > 0
|
||||
assert provider.before_run_context.input_messages[-1].text == "Hello world"
|
||||
|
||||
|
||||
async def test_run_without_context_providers(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that run works normally when no context providers are configured."""
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello")
|
||||
|
||||
response = await agent.run("Hello")
|
||||
|
||||
assert response.text == "Hello"
|
||||
|
||||
|
||||
async def test_run_creates_session_for_providers_when_none_provided(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a session is auto-created when context providers are configured but no session is passed."""
|
||||
provider = TrackingContextProvider()
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
context_providers=[provider],
|
||||
http_client=None,
|
||||
)
|
||||
mock_a2a_client.add_message_response("msg-1", "Hello")
|
||||
|
||||
await agent.run("Hello")
|
||||
|
||||
assert provider.before_run_called
|
||||
assert provider.after_run_called
|
||||
|
||||
|
||||
@mark.parametrize("messages", [None, []])
|
||||
async def test_run_raises_when_no_messages_and_no_continuation_token(
|
||||
mock_a2a_client: MockA2AClient, messages: list[str] | None
|
||||
) -> None:
|
||||
"""Test that run() raises ValueError when messages is None/empty and no continuation_token is provided."""
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
|
||||
with raises(ValueError, match="At least one message is required"):
|
||||
await agent.run(messages)
|
||||
|
||||
|
||||
async def test_run_with_continuation_token_does_not_require_messages(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that run() does not raise when messages is None but a continuation_token is provided."""
|
||||
task = Task(
|
||||
id="task-cont",
|
||||
context_id="ctx-cont",
|
||||
status=TaskStatus(state=TaskState.completed, message=None),
|
||||
)
|
||||
mock_a2a_client.resubscribe_responses.append((task, None))
|
||||
|
||||
agent = A2AAgent(
|
||||
name="Test Agent",
|
||||
client=mock_a2a_client,
|
||||
http_client=None,
|
||||
)
|
||||
|
||||
token = A2AContinuationToken(task_id="task-cont", context_id="ctx-cont")
|
||||
response = await agent.run(None, continuation_token=token)
|
||||
assert response is not None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -21,6 +21,7 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
@@ -369,6 +370,24 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]:
|
||||
return events
|
||||
|
||||
|
||||
def _make_approval_tool_result_events(resolved_approval_results: list[Content]) -> list[ToolCallResultEvent]:
|
||||
"""Build TOOL_CALL_RESULT events for tools executed during approval resolution."""
|
||||
events: list[ToolCallResultEvent] = []
|
||||
for resolved in resolved_approval_results:
|
||||
if resolved.call_id:
|
||||
raw = resolved.result if resolved.result is not None else ""
|
||||
result_str = raw if isinstance(raw, str) else json.dumps(make_json_safe(raw))
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=generate_event_id(),
|
||||
tool_call_id=resolved.call_id,
|
||||
content=result_str,
|
||||
role="tool",
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def _evict_oldest_approvals(registry: dict[str, str], max_size: int = 10_000) -> None:
|
||||
"""Evict the oldest entries from the pending-approvals registry (LRU).
|
||||
|
||||
@@ -391,7 +410,7 @@ async def _resolve_approval_responses(
|
||||
run_kwargs: dict[str, Any],
|
||||
pending_approvals: dict[str, str] | None = None,
|
||||
thread_id: str = "",
|
||||
) -> None:
|
||||
) -> list[Content]:
|
||||
"""Execute approved function calls and replace approval content with results.
|
||||
|
||||
This modifies the messages list in place, replacing function_approval_response
|
||||
@@ -407,10 +426,16 @@ async def _resolve_approval_responses(
|
||||
When provided, every approval response is validated against this
|
||||
registry to prevent bypass, function name spoofing, and replay.
|
||||
thread_id: The conversation thread ID used to scope registry keys.
|
||||
|
||||
Returns:
|
||||
List of approved function_result Content objects only (empty if no
|
||||
approvals). Rejection results are written into the message history
|
||||
but are *not* included in the return value because they should not
|
||||
be emitted as TOOL_CALL_RESULT events.
|
||||
"""
|
||||
fcc_todo = _collect_approval_responses(messages)
|
||||
if not fcc_todo:
|
||||
return
|
||||
return []
|
||||
|
||||
approved_responses = [resp for resp in fcc_todo.values() if resp.approved]
|
||||
rejected_responses = [resp for resp in fcc_todo.values() if not resp.approved]
|
||||
@@ -493,31 +518,23 @@ async def _resolve_approval_responses(
|
||||
logger.exception("Failed to execute approved tool calls; injecting error results: %s", e)
|
||||
approved_function_results = []
|
||||
|
||||
# Build normalized results for approved responses
|
||||
normalized_results: list[Content] = []
|
||||
# Build results for approved responses (used for TOOL_CALL_RESULT event emission)
|
||||
approved_results: list[Content] = []
|
||||
for idx, approval in enumerate(approved_responses):
|
||||
if (
|
||||
idx < len(approved_function_results)
|
||||
and getattr(approved_function_results[idx], "type", None) == "function_result"
|
||||
):
|
||||
normalized_results.append(approved_function_results[idx])
|
||||
approved_results.append(approved_function_results[idx])
|
||||
continue
|
||||
# Get call_id from function_call if present, otherwise use approval.id
|
||||
func_call = approval.function_call
|
||||
call_id = (func_call.call_id if func_call else None) or approval.id or ""
|
||||
normalized_results.append(
|
||||
approved_results.append(
|
||||
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")
|
||||
)
|
||||
|
||||
# Build rejection results
|
||||
for rejection in rejected_responses:
|
||||
func_call = rejection.function_call
|
||||
call_id = (func_call.call_id if func_call else None) or rejection.id or ""
|
||||
normalized_results.append(
|
||||
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation was rejected by user.")
|
||||
)
|
||||
|
||||
_replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore
|
||||
_replace_approval_contents_with_results(messages, fcc_todo, approved_results) # type: ignore
|
||||
|
||||
# Post-process: Convert user messages with function_result content to proper tool messages.
|
||||
# After _replace_approval_contents_with_results, approved tool calls have their results
|
||||
@@ -525,6 +542,8 @@ async def _resolve_approval_responses(
|
||||
# This transformation ensures the message history is valid for the LLM provider.
|
||||
_convert_approval_results_to_tool_messages(messages)
|
||||
|
||||
return approved_results
|
||||
|
||||
|
||||
def _convert_approval_results_to_tool_messages(messages: list[Message]) -> None:
|
||||
"""Convert function_result content in user messages to proper tool messages.
|
||||
@@ -787,7 +806,9 @@ async def run_agent_stream(
|
||||
# Resolve approval responses (execute approved tools, replace approvals with results)
|
||||
# This must happen before running the agent so it sees the tool results
|
||||
tools_for_execution = tools if tools is not None else server_tools
|
||||
await _resolve_approval_responses(messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id)
|
||||
resolved_approval_results = await _resolve_approval_responses(
|
||||
messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id
|
||||
)
|
||||
|
||||
# Defense-in-depth: replace approval payloads in snapshot with actual tool results
|
||||
# so CopilotKit does not re-send stale approval content on subsequent turns.
|
||||
@@ -851,6 +872,9 @@ async def run_agent_stream(
|
||||
yield StateSnapshotEvent(snapshot=flow.current_state)
|
||||
run_started_emitted = True
|
||||
|
||||
for event in _make_approval_tool_result_events(resolved_approval_results):
|
||||
yield event
|
||||
|
||||
# Feature #4: Detect tool-only messages (no text content)
|
||||
# Emit TextMessageStartEvent to create message context for tool calls
|
||||
if not flow.message_id and _has_only_tool_calls(update.contents):
|
||||
@@ -905,7 +929,8 @@ async def run_agent_stream(
|
||||
if state_schema and flow.current_state:
|
||||
yield StateSnapshotEvent(snapshot=flow.current_state)
|
||||
|
||||
# Process structured output if response_format is set
|
||||
for event in _make_approval_tool_result_events(resolved_approval_results):
|
||||
yield event
|
||||
if response_format is not None and all_updates:
|
||||
from agent_framework import AgentResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -111,8 +111,8 @@ def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClien
|
||||
|
||||
@_apply_server_function_call_unwrap
|
||||
class AGUIChatClient(
|
||||
ChatMiddlewareLayer[AGUIChatOptionsT],
|
||||
FunctionInvocationLayer[AGUIChatOptionsT],
|
||||
ChatMiddlewareLayer[AGUIChatOptionsT],
|
||||
ChatTelemetryLayer[AGUIChatOptionsT],
|
||||
BaseChatClient[AGUIChatOptionsT],
|
||||
Generic[AGUIChatOptionsT],
|
||||
|
||||
@@ -12,6 +12,12 @@ from typing import Any, cast
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
ReasoningEncryptedValueEvent,
|
||||
ReasoningEndEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
RunFinishedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
@@ -224,27 +230,28 @@ def _emit_tool_call(
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
def _emit_tool_result_common(
|
||||
call_id: str,
|
||||
raw_result: Any,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
"""Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup.
|
||||
|
||||
Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result``
|
||||
(MCP server tool results) delegate to this function.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
if not content.call_id:
|
||||
return events
|
||||
events.append(ToolCallEndEvent(tool_call_id=call_id))
|
||||
flow.tool_calls_ended.add(call_id)
|
||||
|
||||
events.append(ToolCallEndEvent(tool_call_id=content.call_id))
|
||||
flow.tool_calls_ended.add(content.call_id)
|
||||
|
||||
raw_result = content.result if content.result is not None else ""
|
||||
result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result))
|
||||
message_id = generate_event_id()
|
||||
events.append(
|
||||
ToolCallResultEvent(
|
||||
message_id=message_id,
|
||||
tool_call_id=content.call_id,
|
||||
tool_call_id=call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
@@ -254,7 +261,7 @@ def _emit_tool_result(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": content.call_id,
|
||||
"toolCallId": call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
@@ -268,7 +275,7 @@ def _emit_tool_result(
|
||||
flow.tool_call_name = None
|
||||
|
||||
if flow.message_id:
|
||||
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
|
||||
logger.debug("Closing text message: message_id=%s", flow.message_id)
|
||||
events.append(TextMessageEndEvent(message_id=flow.message_id))
|
||||
flow.message_id = None
|
||||
flow.accumulated_text = ""
|
||||
@@ -276,6 +283,18 @@ def _emit_tool_result(
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
if not content.call_id:
|
||||
return []
|
||||
raw_result = content.result if content.result is not None else ""
|
||||
return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler)
|
||||
|
||||
|
||||
def _emit_approval_request(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
@@ -381,6 +400,107 @@ def _emit_oauth_consent(content: Content) -> list[BaseEvent]:
|
||||
)
|
||||
|
||||
|
||||
def _emit_mcp_tool_call(content: Content, flow: FlowState) -> list[BaseEvent]:
|
||||
"""Emit ToolCall start/args events for MCP server tool call content.
|
||||
|
||||
MCP tool calls arrive as complete items (not streamed deltas), so we emit a
|
||||
``ToolCallStartEvent`` (and, when arguments are present, a ``ToolCallArgsEvent``)
|
||||
immediately. This maps MCP-specific fields (tool_name, server_name) to the
|
||||
same AG-UI ToolCall* events used by regular function calls, making MCP tool
|
||||
execution visible to AG-UI consumers. Completion/end events are handled
|
||||
separately by ``_emit_mcp_tool_result``.
|
||||
"""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
tool_call_id = content.call_id or generate_event_id()
|
||||
tool_name = content.tool_name or "mcp_tool"
|
||||
|
||||
display_name = tool_name
|
||||
|
||||
events.append(
|
||||
ToolCallStartEvent(
|
||||
tool_call_id=tool_call_id,
|
||||
tool_call_name=display_name,
|
||||
parent_message_id=flow.message_id,
|
||||
)
|
||||
)
|
||||
|
||||
# Serialize arguments
|
||||
args_str = ""
|
||||
if content.arguments:
|
||||
args_str = (
|
||||
content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments))
|
||||
)
|
||||
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_str))
|
||||
|
||||
# Track in flow state for MESSAGES_SNAPSHOT
|
||||
tool_entry = {
|
||||
"id": tool_call_id,
|
||||
"type": "function",
|
||||
"function": {"name": display_name, "arguments": args_str},
|
||||
}
|
||||
flow.pending_tool_calls.append(tool_entry)
|
||||
flow.tool_calls_by_id[tool_call_id] = tool_entry
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_mcp_tool_result(
|
||||
content: Content, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None
|
||||
) -> list[BaseEvent]:
|
||||
"""Emit ToolCallResult events for MCP server tool result content.
|
||||
|
||||
Delegates to the shared _emit_tool_result_common helper using content.output
|
||||
(the MCP-specific result field) instead of content.result.
|
||||
"""
|
||||
if not content.call_id:
|
||||
logger.warning("MCP tool result content missing call_id, skipping")
|
||||
return []
|
||||
raw_output = content.output if content.output is not None else ""
|
||||
return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler)
|
||||
|
||||
|
||||
def _emit_text_reasoning(content: Content) -> list[BaseEvent]:
|
||||
"""Emit AG-UI reasoning events for text_reasoning content.
|
||||
|
||||
Uses the protocol-defined reasoning event types so that AG-UI consumers
|
||||
such as CopilotKit can render reasoning natively.
|
||||
|
||||
Only ``content.text`` is used for the visible reasoning message. If
|
||||
``content.protected_data`` is present it is emitted as a
|
||||
``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted
|
||||
reasoning for state continuity without conflating it with display text.
|
||||
"""
|
||||
text = content.text or ""
|
||||
if not text and content.protected_data is None:
|
||||
return []
|
||||
|
||||
message_id = content.id or generate_event_id()
|
||||
|
||||
events: list[BaseEvent] = [
|
||||
ReasoningStartEvent(message_id=message_id),
|
||||
ReasoningMessageStartEvent(message_id=message_id, role="assistant"),
|
||||
]
|
||||
|
||||
if text:
|
||||
events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text))
|
||||
|
||||
events.append(ReasoningMessageEndEvent(message_id=message_id))
|
||||
|
||||
if content.protected_data is not None:
|
||||
events.append(
|
||||
ReasoningEncryptedValueEvent(
|
||||
subtype="message",
|
||||
entity_id=message_id,
|
||||
encrypted_value=content.protected_data,
|
||||
)
|
||||
)
|
||||
|
||||
events.append(ReasoningEndEvent(message_id=message_id))
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def _emit_content(
|
||||
content: Any,
|
||||
flow: FlowState,
|
||||
@@ -402,5 +522,11 @@ def _emit_content(
|
||||
return _emit_usage(content)
|
||||
if content_type == "oauth_consent_request":
|
||||
return _emit_oauth_consent(content)
|
||||
if content_type == "mcp_server_tool_call":
|
||||
return _emit_mcp_tool_call(content, flow)
|
||||
if content_type == "mcp_server_tool_result":
|
||||
return _emit_mcp_tool_result(content, flow, predictive_handler)
|
||||
if content_type == "text_reasoning":
|
||||
return _emit_text_reasoning(content)
|
||||
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
description = "AG-UI protocol integration for Agent Framework"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
@@ -72,6 +72,10 @@ typeCheckingMode = "basic"
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_ag_ui"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_ag_ui --cov-report=term-missing:skip-covered -n auto --dist worksteal tests/ag_ui'
|
||||
|
||||
@@ -45,8 +45,8 @@ def pytest_configure() -> None:
|
||||
|
||||
|
||||
class StreamingChatClientStub(
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
FunctionInvocationLayer[OptionsCoT],
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
ChatTelemetryLayer[OptionsCoT],
|
||||
BaseChatClient[OptionsCoT],
|
||||
Generic[OptionsCoT],
|
||||
@@ -54,7 +54,7 @@ class StreamingChatClientStub(
|
||||
"""Typed streaming stub that satisfies SupportsChatGetResponse."""
|
||||
|
||||
def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None:
|
||||
super().__init__(function_middleware=[])
|
||||
super().__init__(middleware=[])
|
||||
self._stream_fn = stream_fn
|
||||
self._response_fn = response_fn
|
||||
self.last_session: AgentSession | None = None
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for TOOL_CALL_RESULT event emission on approval resume flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponseUpdate, Content, FunctionTool
|
||||
from conftest import StubAgent
|
||||
|
||||
from agent_framework_ag_ui._agent import AgentConfig
|
||||
from agent_framework_ag_ui._agent_run import run_agent_stream
|
||||
|
||||
|
||||
def _make_weather_tool() -> FunctionTool:
|
||||
"""Create a real executable weather tool with approval_mode='always_require'."""
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
return f"Sunny in {city}"
|
||||
|
||||
return FunctionTool(
|
||||
name="get_weather",
|
||||
description="Get the weather for a city",
|
||||
func=get_weather,
|
||||
approval_mode="always_require",
|
||||
)
|
||||
|
||||
|
||||
async def test_approval_resume_emits_tool_call_result() -> None:
|
||||
"""After approving a tool call, the resume stream should contain a TOOL_CALL_RESULT event.
|
||||
|
||||
The message format follows the AG-UI approval pattern:
|
||||
- assistant message with tool_calls
|
||||
- tool message with {"accepted": true} content and toolCallId
|
||||
"""
|
||||
tool_name = "get_weather"
|
||||
call_id = "call_abc123"
|
||||
weather_tool = _make_weather_tool()
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="The weather is sunny.")], role="assistant")],
|
||||
default_options={"tools": [weather_tool]},
|
||||
)
|
||||
config = AgentConfig()
|
||||
|
||||
# Build resume messages: user query, assistant tool call, approval response
|
||||
resume_messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "What's the weather in Seattle?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": json.dumps({"city": "Seattle"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": True}),
|
||||
"toolCallId": call_id,
|
||||
},
|
||||
]
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-approval-result",
|
||||
"run_id": "run-resume",
|
||||
"messages": resume_messages,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
event_types = [getattr(e, "type", None) for e in events]
|
||||
|
||||
assert "RUN_STARTED" in event_types, f"Expected RUN_STARTED, got types: {event_types}"
|
||||
assert "RUN_FINISHED" in event_types, f"Expected RUN_FINISHED, got types: {event_types}"
|
||||
|
||||
# TOOL_CALL_RESULT must be present for the approved tool
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
|
||||
assert len(tool_result_events) > 0, (
|
||||
f"Expected at least one TOOL_CALL_RESULT event for the approved tool, "
|
||||
f"but found none. Event types in stream: {event_types}"
|
||||
)
|
||||
|
||||
result_event = tool_result_events[0]
|
||||
assert result_event.tool_call_id == call_id, (
|
||||
f"Expected TOOL_CALL_RESULT with tool_call_id={call_id}, got tool_call_id={result_event.tool_call_id}"
|
||||
)
|
||||
# Verify the result contains the actual tool execution output
|
||||
assert result_event.content == "Sunny in Seattle"
|
||||
|
||||
|
||||
async def test_approval_resume_result_has_content() -> None:
|
||||
"""TOOL_CALL_RESULT event from an approved tool should contain the execution result."""
|
||||
tool_name = "get_weather"
|
||||
call_id = "call_content_check"
|
||||
weather_tool = _make_weather_tool()
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")],
|
||||
default_options={"tools": [weather_tool]},
|
||||
)
|
||||
config = AgentConfig()
|
||||
|
||||
resume_messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "Check the weather"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": json.dumps({"city": "Portland"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": True}),
|
||||
"toolCallId": call_id,
|
||||
},
|
||||
]
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-result-content",
|
||||
"run_id": "run-resume-2",
|
||||
"messages": resume_messages,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
assert len(tool_result_events) == 1
|
||||
|
||||
result_event = tool_result_events[0]
|
||||
assert result_event.tool_call_id == call_id
|
||||
assert result_event.role == "tool"
|
||||
# Verify the result contains the actual tool execution output (string returned directly)
|
||||
assert result_event.content == "Sunny in Portland"
|
||||
|
||||
|
||||
async def test_no_approval_no_extra_tool_result() -> None:
|
||||
"""When no approval response is present, no extra TOOL_CALL_RESULT events should be emitted."""
|
||||
agent = StubAgent(updates=[AgentResponseUpdate(contents=[Content.from_text(text="Hello.")], role="assistant")])
|
||||
config = AgentConfig()
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-no-approval",
|
||||
"run_id": "run-normal",
|
||||
"messages": [{"role": "user", "content": "Hi"}],
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
assert len(tool_result_events) == 0, f"Unexpected TOOL_CALL_RESULT events: {tool_result_events}"
|
||||
|
||||
|
||||
async def test_rejection_does_not_emit_tool_call_result() -> None:
|
||||
"""Rejected tool calls should not produce TOOL_CALL_RESULT events."""
|
||||
tool_name = "get_weather"
|
||||
call_id = "call_rejected"
|
||||
weather_tool = _make_weather_tool()
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="OK, I won't check.")], role="assistant")],
|
||||
default_options={"tools": [weather_tool]},
|
||||
)
|
||||
config = AgentConfig()
|
||||
|
||||
resume_messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": json.dumps({"city": "Denver"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": False}),
|
||||
"toolCallId": call_id,
|
||||
},
|
||||
]
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-rejection",
|
||||
"run_id": "run-rejected",
|
||||
"messages": resume_messages,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
assert len(tool_result_events) == 0, (
|
||||
f"Expected no TOOL_CALL_RESULT for rejected tool, got {len(tool_result_events)}"
|
||||
)
|
||||
|
||||
|
||||
def _make_temperature_tool() -> FunctionTool:
|
||||
"""Create a real executable temperature tool with approval_mode='always_require'."""
|
||||
|
||||
def get_temperature(city: str) -> str:
|
||||
return f"72F in {city}"
|
||||
|
||||
return FunctionTool(
|
||||
name="get_temperature",
|
||||
description="Get the temperature for a city",
|
||||
func=get_temperature,
|
||||
approval_mode="always_require",
|
||||
)
|
||||
|
||||
|
||||
async def test_mixed_approve_reject_emits_only_approved_tool_result() -> None:
|
||||
"""When one tool call is approved and another rejected, only the approved one produces a TOOL_CALL_RESULT event."""
|
||||
weather_tool = _make_weather_tool()
|
||||
temperature_tool = _make_temperature_tool()
|
||||
approved_call_id = "call_approved"
|
||||
rejected_call_id = "call_rejected"
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[AgentResponseUpdate(contents=[Content.from_text(text="Here are the results.")], role="assistant")],
|
||||
default_options={"tools": [weather_tool, temperature_tool]},
|
||||
)
|
||||
config = AgentConfig()
|
||||
|
||||
resume_messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "Weather and temperature in Seattle?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": approved_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": json.dumps({"city": "Seattle"}),
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": rejected_call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_temperature",
|
||||
"arguments": json.dumps({"city": "Seattle"}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": True}),
|
||||
"toolCallId": approved_call_id,
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": False}),
|
||||
"toolCallId": rejected_call_id,
|
||||
},
|
||||
]
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-mixed",
|
||||
"run_id": "run-mixed",
|
||||
"messages": resume_messages,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
|
||||
# Only the approved tool call should produce a TOOL_CALL_RESULT event
|
||||
assert len(tool_result_events) == 1, (
|
||||
f"Expected exactly 1 TOOL_CALL_RESULT (approved only), got {len(tool_result_events)}"
|
||||
)
|
||||
assert tool_result_events[0].tool_call_id == approved_call_id
|
||||
assert tool_result_events[0].content == "Sunny in Seattle"
|
||||
|
||||
|
||||
async def test_approval_resume_zero_updates_emits_tool_result() -> None:
|
||||
"""When the agent produces zero updates, TOOL_CALL_RESULT events should still be emitted via the fallback path."""
|
||||
tool_name = "get_weather"
|
||||
call_id = "call_zero_updates"
|
||||
weather_tool = _make_weather_tool()
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[],
|
||||
default_options={"tools": [weather_tool]},
|
||||
)
|
||||
config = AgentConfig()
|
||||
|
||||
resume_messages: list[dict[str, Any]] = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_name,
|
||||
"arguments": json.dumps({"city": "Boston"}),
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": json.dumps({"accepted": True}),
|
||||
"toolCallId": call_id,
|
||||
},
|
||||
]
|
||||
|
||||
input_data: dict[str, Any] = {
|
||||
"thread_id": "thread-zero-updates",
|
||||
"run_id": "run-zero-updates",
|
||||
"messages": resume_messages,
|
||||
}
|
||||
|
||||
events: list[Any] = []
|
||||
async for event in run_agent_stream(input_data, agent, config):
|
||||
events.append(event)
|
||||
|
||||
event_types = [getattr(e, "type", None) for e in events]
|
||||
assert "RUN_STARTED" in event_types
|
||||
|
||||
tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"]
|
||||
assert len(tool_result_events) == 1, (
|
||||
f"Expected 1 TOOL_CALL_RESULT in zero-updates fallback path, got {len(tool_result_events)}"
|
||||
)
|
||||
assert tool_result_events[0].tool_call_id == call_id
|
||||
assert tool_result_events[0].content == "Sunny in Boston"
|
||||
|
||||
|
||||
async def test_resolve_approval_responses_returns_only_approved() -> None:
|
||||
"""_resolve_approval_responses should return only approved results; rejection results go into messages only."""
|
||||
from agent_framework import Message
|
||||
|
||||
from agent_framework_ag_ui._agent_run import _resolve_approval_responses
|
||||
|
||||
weather_tool = _make_weather_tool()
|
||||
temperature_tool = _make_temperature_tool()
|
||||
approved_call_id = "call_a"
|
||||
rejected_call_id = "call_r"
|
||||
|
||||
messages: list[Any] = [
|
||||
Message(role="user", contents=[Content.from_text(text="Hi")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content(
|
||||
type="function_approval_request",
|
||||
id=approved_call_id,
|
||||
function_call=Content(
|
||||
type="function_call",
|
||||
name="get_weather",
|
||||
call_id=approved_call_id,
|
||||
arguments='{"city": "NYC"}',
|
||||
),
|
||||
),
|
||||
Content(
|
||||
type="function_approval_request",
|
||||
id=rejected_call_id,
|
||||
function_call=Content(
|
||||
type="function_call",
|
||||
name="get_temperature",
|
||||
call_id=rejected_call_id,
|
||||
arguments='{"city": "NYC"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
Content(
|
||||
type="function_approval_response",
|
||||
id=approved_call_id,
|
||||
approved=True,
|
||||
function_call=Content(
|
||||
type="function_call",
|
||||
name="get_weather",
|
||||
call_id=approved_call_id,
|
||||
arguments='{"city": "NYC"}',
|
||||
),
|
||||
),
|
||||
Content(
|
||||
type="function_approval_response",
|
||||
id=rejected_call_id,
|
||||
approved=False,
|
||||
function_call=Content(
|
||||
type="function_call",
|
||||
name="get_temperature",
|
||||
call_id=rejected_call_id,
|
||||
arguments='{"city": "NYC"}',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
agent = StubAgent(
|
||||
updates=[],
|
||||
default_options={"tools": [weather_tool, temperature_tool]},
|
||||
)
|
||||
|
||||
results = await _resolve_approval_responses(messages, [weather_tool, temperature_tool], agent, {})
|
||||
|
||||
# Return value should only contain approved results
|
||||
assert len(results) == 1
|
||||
assert results[0].call_id == approved_call_id
|
||||
assert results[0].type == "function_result"
|
||||
|
||||
# Rejection result should be written into messages (by _replace_approval_contents_with_results)
|
||||
all_contents = [c for msg in messages for c in msg.contents]
|
||||
rejection_results = [c for c in all_contents if c.type == "function_result" and c.call_id == rejected_call_id]
|
||||
assert len(rejection_results) == 1
|
||||
assert "rejected" in str(rejection_results[0].result).lower()
|
||||
@@ -213,3 +213,134 @@ def test_sse_response_headers() -> None:
|
||||
|
||||
assert response.headers["content-type"] == "text/event-stream; charset=utf-8"
|
||||
assert response.headers.get("cache-control") == "no-cache"
|
||||
|
||||
|
||||
# ── MCP tool call SSE round-trip ──
|
||||
|
||||
|
||||
def test_mcp_tool_call_sse_round_trip() -> None:
|
||||
"""MCP tool call + result events survive SSE encoding/parsing round-trip."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_call(
|
||||
call_id="mcp-1",
|
||||
tool_name="search",
|
||||
server_name="brave",
|
||||
arguments={"query": "weather"},
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_mcp_server_tool_result(
|
||||
call_id="mcp-1",
|
||||
output={"results": ["sunny"]},
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="It's sunny!")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_tool_calls_balanced()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_no_run_error()
|
||||
|
||||
# Verify MCP tool call details survive SSE encoding
|
||||
start = stream.first("TOOL_CALL_START")
|
||||
assert start.tool_call_name == "search"
|
||||
assert start.tool_call_id == "mcp-1"
|
||||
|
||||
# Verify the result came through
|
||||
result = stream.first("TOOL_CALL_RESULT")
|
||||
assert "sunny" in result.content
|
||||
|
||||
|
||||
# ── Text reasoning SSE round-trip ──
|
||||
|
||||
|
||||
def test_text_reasoning_sse_round_trip() -> None:
|
||||
"""Text reasoning events survive SSE encoding/parsing round-trip."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text_reasoning(
|
||||
id="reason-1",
|
||||
text="The user wants weather info, I should use a tool.",
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Let me check the weather.")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_text_messages_balanced()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_has_type("REASONING_START")
|
||||
stream.assert_has_type("REASONING_MESSAGE_CONTENT")
|
||||
stream.assert_has_type("REASONING_END")
|
||||
|
||||
# Verify reasoning content survives SSE encoding
|
||||
raw_events = parse_sse_response(response.content)
|
||||
reasoning_content = [e for e in raw_events if e["type"] == "REASONING_MESSAGE_CONTENT"]
|
||||
assert len(reasoning_content) == 1
|
||||
assert "weather" in reasoning_content[0]["delta"]
|
||||
|
||||
|
||||
def test_text_reasoning_with_encrypted_value_sse_round_trip() -> None:
|
||||
"""Reasoning with protected_data emits ReasoningEncryptedValue through SSE."""
|
||||
app = _build_app_with_agent(
|
||||
[
|
||||
AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text_reasoning(
|
||||
id="reason-enc",
|
||||
text="visible reasoning",
|
||||
protected_data="encrypted-payload-abc123",
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
),
|
||||
AgentResponseUpdate(
|
||||
contents=[Content.from_text(text="Done.")],
|
||||
role="assistant",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = TestClient(app)
|
||||
response = client.post("/", json=USER_PAYLOAD)
|
||||
|
||||
assert response.status_code == 200
|
||||
stream = parse_sse_to_event_stream(response.content)
|
||||
stream.assert_bookends()
|
||||
stream.assert_no_run_error()
|
||||
stream.assert_has_type("REASONING_ENCRYPTED_VALUE")
|
||||
|
||||
raw_events = parse_sse_response(response.content)
|
||||
encrypted = [e for e in raw_events if e["type"] == "REASONING_ENCRYPTED_VALUE"]
|
||||
assert len(encrypted) == 1
|
||||
assert encrypted[0]["encryptedValue"] == "encrypted-payload-abc123"
|
||||
assert encrypted[0]["entityId"] == "reason-enc"
|
||||
assert encrypted[0]["subtype"] == "message"
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
import pytest
|
||||
from ag_ui.core import (
|
||||
CustomEvent,
|
||||
ReasoningEncryptedValueEvent,
|
||||
ReasoningEndEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
@@ -25,7 +31,10 @@ from agent_framework_ag_ui._run_common import (
|
||||
_build_run_finished_event,
|
||||
_emit_approval_request,
|
||||
_emit_content,
|
||||
_emit_mcp_tool_call,
|
||||
_emit_mcp_tool_result,
|
||||
_emit_text,
|
||||
_emit_text_reasoning,
|
||||
_emit_tool_call,
|
||||
_emit_tool_result,
|
||||
_extract_resume_payload,
|
||||
@@ -991,3 +1000,349 @@ def test_emit_oauth_consent_request_no_link():
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tests for MCP tool call, MCP tool result, and text reasoning event emission
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestEmitMcpToolCall:
|
||||
"""Tests for _emit_mcp_tool_call function."""
|
||||
|
||||
def test_produces_start_and_args_events(self):
|
||||
"""MCP tool call emits ToolCallStart + ToolCallArgs events."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_call_1",
|
||||
tool_name="search",
|
||||
server_name="brave",
|
||||
arguments={"query": "weather"},
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_START"
|
||||
assert events[0].tool_call_id == "mcp_call_1"
|
||||
assert events[0].tool_call_name == "search"
|
||||
assert events[1].type == "TOOL_CALL_ARGS"
|
||||
assert events[1].tool_call_id == "mcp_call_1"
|
||||
assert "weather" in events[1].delta
|
||||
|
||||
def test_tracks_in_flow_state(self):
|
||||
"""MCP tool call is tracked in flow.pending_tool_calls and tool_calls_by_id."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_call_2",
|
||||
tool_name="get_file",
|
||||
arguments='{"path": "/tmp/test.txt"}',
|
||||
)
|
||||
|
||||
_emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert len(flow.pending_tool_calls) == 1
|
||||
assert flow.pending_tool_calls[0]["id"] == "mcp_call_2"
|
||||
assert "mcp_call_2" in flow.tool_calls_by_id
|
||||
assert flow.tool_calls_by_id["mcp_call_2"]["function"]["name"] == "get_file"
|
||||
assert flow.tool_calls_by_id["mcp_call_2"]["function"]["arguments"] == '{"path": "/tmp/test.txt"}'
|
||||
|
||||
def test_no_server_name_uses_tool_name_only(self):
|
||||
"""Without server_name, display name is just tool_name."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_call_3",
|
||||
tool_name="list_files",
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert events[0].tool_call_name == "list_files"
|
||||
|
||||
def test_no_arguments_skips_args_event(self):
|
||||
"""No arguments produces only ToolCallStart, no ToolCallArgs."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="mcp_call_4",
|
||||
tool_name="ping",
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].type == "TOOL_CALL_START"
|
||||
|
||||
def test_generates_id_when_missing(self):
|
||||
"""A tool_call_id is generated when call_id is None."""
|
||||
flow = FlowState()
|
||||
content = Content(type="mcp_server_tool_call", tool_name="test_tool")
|
||||
|
||||
events = _emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert len(events) >= 1
|
||||
assert events[0].tool_call_id is not None
|
||||
assert events[0].tool_call_id != ""
|
||||
assert events[0].tool_call_name == "test_tool"
|
||||
|
||||
def test_missing_tool_name_falls_back_to_mcp_tool(self):
|
||||
"""When tool_name is None, the fallback 'mcp_tool' is used."""
|
||||
flow = FlowState()
|
||||
content = Content(type="mcp_server_tool_call")
|
||||
|
||||
events = _emit_mcp_tool_call(content, flow)
|
||||
|
||||
assert len(events) >= 1
|
||||
assert events[0].tool_call_name == "mcp_tool"
|
||||
|
||||
|
||||
class TestEmitMcpToolResult:
|
||||
"""Tests for _emit_mcp_tool_result function."""
|
||||
|
||||
def test_produces_end_and_result_events(self):
|
||||
"""MCP tool result emits ToolCallEnd + ToolCallResult events."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_1",
|
||||
output={"results": [{"title": "Weather", "url": "https://example.com"}]},
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[0].tool_call_id == "mcp_call_1"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
assert events[1].tool_call_id == "mcp_call_1"
|
||||
assert "Weather" in events[1].content
|
||||
|
||||
def test_tracks_in_flow_state(self):
|
||||
"""MCP tool result is tracked in flow.tool_results and tool_calls_ended."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_5",
|
||||
output="Success",
|
||||
)
|
||||
|
||||
_emit_mcp_tool_result(content, flow)
|
||||
|
||||
assert "mcp_call_5" in flow.tool_calls_ended
|
||||
assert len(flow.tool_results) == 1
|
||||
assert flow.tool_results[0]["toolCallId"] == "mcp_call_5"
|
||||
assert flow.tool_results[0]["content"] == "Success"
|
||||
|
||||
def test_no_call_id_returns_empty(self):
|
||||
"""Missing call_id returns empty events list with a warning."""
|
||||
flow = FlowState()
|
||||
content = Content(type="mcp_server_tool_result", output="data")
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_serializes_non_string_output(self):
|
||||
"""Non-string output is serialized to JSON."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_6",
|
||||
output={"key": "value", "count": 42},
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
result_event = events[1]
|
||||
assert isinstance(result_event.content, str)
|
||||
assert '"key": "value"' in result_event.content
|
||||
|
||||
def test_output_none_falls_back_to_empty_string(self):
|
||||
"""When output is None (default), the result content is an empty string."""
|
||||
flow = FlowState()
|
||||
content = Content(type="mcp_server_tool_result", call_id="mcp_call_none")
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
assert events[1].content == ""
|
||||
|
||||
def test_resets_flow_state_like_emit_tool_result(self):
|
||||
"""MCP tool result performs same FlowState cleanup as _emit_tool_result."""
|
||||
flow = FlowState()
|
||||
flow.tool_call_id = "mcp_call_7"
|
||||
flow.tool_call_name = "brave/search"
|
||||
flow.message_id = "open-msg-456"
|
||||
flow.accumulated_text = "Let me search for that..."
|
||||
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_7",
|
||||
output="search results",
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
assert flow.tool_call_id is None
|
||||
assert flow.tool_call_name is None
|
||||
assert flow.message_id is None
|
||||
assert flow.accumulated_text == ""
|
||||
|
||||
text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
|
||||
assert len(text_end_events) == 1
|
||||
assert text_end_events[0].message_id == "open-msg-456"
|
||||
|
||||
def test_no_open_message_skips_text_end(self):
|
||||
"""MCP tool result without open text message skips TextMessageEndEvent."""
|
||||
flow = FlowState()
|
||||
flow.message_id = None
|
||||
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_8",
|
||||
output="result",
|
||||
)
|
||||
|
||||
events = _emit_mcp_tool_result(content, flow)
|
||||
|
||||
text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)]
|
||||
assert len(text_end_events) == 0
|
||||
|
||||
def test_predictive_handler_emits_state_snapshot(self):
|
||||
"""MCP tool result applies pending updates and emits StateSnapshotEvent when predictive_handler is set."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from ag_ui.core import StateSnapshotEvent
|
||||
|
||||
flow = FlowState()
|
||||
flow.current_state = {"doc": "hello"}
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="mcp_call_9",
|
||||
output="done",
|
||||
)
|
||||
|
||||
handler = MagicMock()
|
||||
events = _emit_mcp_tool_result(content, flow, predictive_handler=handler)
|
||||
|
||||
handler.apply_pending_updates.assert_called_once()
|
||||
snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)]
|
||||
assert len(snapshot_events) == 1
|
||||
assert snapshot_events[0].snapshot == {"doc": "hello"}
|
||||
|
||||
|
||||
class TestEmitTextReasoning:
|
||||
"""Tests for _emit_text_reasoning function."""
|
||||
|
||||
def test_produces_reasoning_events(self):
|
||||
"""Text reasoning emits the full reasoning event sequence."""
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_1",
|
||||
text="The user is asking about weather, so I should call the weather tool.",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
assert len(events) == 5
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
assert events[0].message_id == "reason_1"
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert events[1].message_id == "reason_1"
|
||||
assert events[1].role == "assistant"
|
||||
assert isinstance(events[2], ReasoningMessageContentEvent)
|
||||
assert events[2].message_id == "reason_1"
|
||||
assert events[2].delta == "The user is asking about weather, so I should call the weather tool."
|
||||
assert isinstance(events[3], ReasoningMessageEndEvent)
|
||||
assert events[3].message_id == "reason_1"
|
||||
assert isinstance(events[4], ReasoningEndEvent)
|
||||
assert events[4].message_id == "reason_1"
|
||||
|
||||
def test_protected_data_emits_encrypted_value_event(self):
|
||||
"""protected_data is emitted as a ReasoningEncryptedValueEvent."""
|
||||
content = Content.from_text_reasoning(
|
||||
id="reason_2",
|
||||
text="visible reasoning",
|
||||
protected_data="encrypted metadata",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
encrypted_events = [e for e in events if isinstance(e, ReasoningEncryptedValueEvent)]
|
||||
assert len(encrypted_events) == 1
|
||||
assert encrypted_events[0].subtype == "message"
|
||||
assert encrypted_events[0].entity_id == "reason_2"
|
||||
assert encrypted_events[0].encrypted_value == "encrypted metadata"
|
||||
|
||||
def test_protected_data_only_emits_event(self):
|
||||
"""Content with only protected_data (no text) still emits reasoning events."""
|
||||
content = Content.from_text_reasoning(
|
||||
protected_data="encrypted reasoning content",
|
||||
)
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
# Should have start, msg_start, msg_end, encrypted_value, end (no content event)
|
||||
assert len(events) == 5
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
assert isinstance(events[1], ReasoningMessageStartEvent)
|
||||
assert isinstance(events[2], ReasoningMessageEndEvent)
|
||||
assert isinstance(events[3], ReasoningEncryptedValueEvent)
|
||||
assert events[3].encrypted_value == "encrypted reasoning content"
|
||||
assert isinstance(events[4], ReasoningEndEvent)
|
||||
|
||||
def test_empty_text_and_no_protected_data_returns_empty(self):
|
||||
"""Empty text and no protected_data returns no events."""
|
||||
content = Content.from_text_reasoning()
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
assert events == []
|
||||
|
||||
def test_generates_message_id_when_missing(self):
|
||||
"""When id is None, a message_id is generated."""
|
||||
content = Content.from_text_reasoning(text="thinking...")
|
||||
|
||||
events = _emit_text_reasoning(content)
|
||||
|
||||
assert len(events) == 5
|
||||
assert events[0].message_id is not None
|
||||
assert events[0].message_id != ""
|
||||
# All events share the same message_id
|
||||
assert events[1].message_id == events[0].message_id
|
||||
|
||||
|
||||
class TestEmitContentMcpRouting:
|
||||
"""Tests that _emit_content correctly routes MCP and reasoning types."""
|
||||
|
||||
def test_routes_mcp_server_tool_call(self):
|
||||
"""_emit_content dispatches mcp_server_tool_call to _emit_mcp_tool_call."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_call(
|
||||
call_id="route_test_1",
|
||||
tool_name="test_tool",
|
||||
server_name="test_server",
|
||||
)
|
||||
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) >= 1
|
||||
assert events[0].type == "TOOL_CALL_START"
|
||||
assert events[0].tool_call_name == "test_tool"
|
||||
|
||||
def test_routes_mcp_server_tool_result(self):
|
||||
"""_emit_content dispatches mcp_server_tool_result to _emit_mcp_tool_result."""
|
||||
flow = FlowState()
|
||||
content = Content.from_mcp_server_tool_result(
|
||||
call_id="route_test_2",
|
||||
output="result data",
|
||||
)
|
||||
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) == 2
|
||||
assert events[0].type == "TOOL_CALL_END"
|
||||
assert events[1].type == "TOOL_CALL_RESULT"
|
||||
|
||||
def test_routes_text_reasoning(self):
|
||||
"""_emit_content dispatches text_reasoning to _emit_text_reasoning."""
|
||||
flow = FlowState()
|
||||
content = Content.from_text_reasoning(text="I need to think about this...")
|
||||
|
||||
events = _emit_content(content, flow)
|
||||
|
||||
assert len(events) == 5
|
||||
assert isinstance(events[0], ReasoningStartEvent)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._chat_client import AnthropicChatOptions, AnthropicClient
|
||||
from ._chat_client import AnthropicChatOptions, AnthropicClient, RawAnthropicClient
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,5 +12,6 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__all__ = [
|
||||
"AnthropicChatOptions",
|
||||
"AnthropicClient",
|
||||
"RawAnthropicClient",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -68,6 +68,7 @@ else:
|
||||
__all__ = [
|
||||
"AnthropicChatOptions",
|
||||
"AnthropicClient",
|
||||
"RawAnthropicClient",
|
||||
"ThinkingConfig",
|
||||
]
|
||||
|
||||
@@ -210,14 +211,24 @@ class AnthropicSettings(TypedDict, total=False):
|
||||
chat_model_id: str | None
|
||||
|
||||
|
||||
class AnthropicClient(
|
||||
ChatMiddlewareLayer[AnthropicOptionsT],
|
||||
FunctionInvocationLayer[AnthropicOptionsT],
|
||||
ChatTelemetryLayer[AnthropicOptionsT],
|
||||
class RawAnthropicClient(
|
||||
BaseChatClient[AnthropicOptionsT],
|
||||
Generic[AnthropicOptionsT],
|
||||
):
|
||||
"""Anthropic Chat client with middleware, telemetry, and function invocation support."""
|
||||
"""Raw Anthropic chat client without middleware, telemetry, or function invocation support.
|
||||
|
||||
Warning:
|
||||
**This class should not normally be used directly.** It does not include middleware,
|
||||
telemetry, or function invocation support that you most likely need. If you do use it,
|
||||
you should consider which additional layers to apply. There is a defined ordering that
|
||||
you should follow:
|
||||
|
||||
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
|
||||
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
|
||||
|
||||
Use ``AnthropicClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
@@ -229,12 +240,10 @@ class AnthropicClient(
|
||||
anthropic_client: AsyncAnthropic | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize an Anthropic Agent client.
|
||||
"""Initialize a raw Anthropic client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
@@ -245,15 +254,13 @@ class AnthropicClient(
|
||||
additional_beta_flags: Additional beta flags to enable on the client.
|
||||
Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25".
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional middleware to apply to the client.
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.anthropic import AnthropicClient
|
||||
from agent_framework.anthropic import RawAnthropicClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Using environment variables
|
||||
@@ -261,13 +268,13 @@ class AnthropicClient(
|
||||
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
|
||||
|
||||
# Or passing parameters directly
|
||||
client = AnthropicClient(
|
||||
client = RawAnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = AnthropicClient(env_file_path="path/to/.env")
|
||||
client = RawAnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
# Or passing in an existing client
|
||||
from anthropic import AsyncAnthropic
|
||||
@@ -275,7 +282,7 @@ class AnthropicClient(
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
|
||||
)
|
||||
client = AnthropicClient(
|
||||
client = RawAnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
anthropic_client=anthropic_client,
|
||||
)
|
||||
@@ -289,7 +296,7 @@ class AnthropicClient(
|
||||
my_custom_option: str
|
||||
|
||||
|
||||
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
|
||||
client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929")
|
||||
response = await client.get_response("Hello", options={"my_custom_option": "value"})
|
||||
|
||||
"""
|
||||
@@ -320,8 +327,6 @@ class AnthropicClient(
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
@@ -1376,3 +1381,95 @@ class AnthropicClient(
|
||||
The service URL for the chat client, or None if not set.
|
||||
"""
|
||||
return str(self.anthropic_client.base_url)
|
||||
|
||||
|
||||
class AnthropicClient(
|
||||
FunctionInvocationLayer[AnthropicOptionsT],
|
||||
ChatMiddlewareLayer[AnthropicOptionsT],
|
||||
ChatTelemetryLayer[AnthropicOptionsT],
|
||||
RawAnthropicClient[AnthropicOptionsT],
|
||||
Generic[AnthropicOptionsT],
|
||||
):
|
||||
"""Anthropic chat client with middleware, telemetry, and function invocation support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str | None = None,
|
||||
model_id: str | None = None,
|
||||
anthropic_client: AsyncAnthropic | None = None,
|
||||
additional_beta_flags: list[str] | None = None,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
env_file_path: str | None = None,
|
||||
env_file_encoding: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize an Anthropic client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
model_id: The ID of the model to use.
|
||||
anthropic_client: An existing Anthropic client to use. If not provided, one will be created.
|
||||
This can be used to further configure the client before passing it in.
|
||||
For instance if you need to set a different base_url for testing or private deployments.
|
||||
additional_beta_flags: Additional beta flags to enable on the client.
|
||||
Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25".
|
||||
additional_properties: Additional properties stored on the client instance.
|
||||
middleware: Optional middleware to apply to the client.
|
||||
function_invocation_configuration: Optional function invocation configuration override.
|
||||
env_file_path: Path to environment file for loading settings.
|
||||
env_file_encoding: Encoding of the environment file.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
|
||||
from agent_framework.anthropic import AnthropicClient
|
||||
|
||||
# Using environment variables
|
||||
# Set ANTHROPIC_API_KEY=your_anthropic_api_key
|
||||
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
|
||||
|
||||
# Or passing parameters directly
|
||||
client = AnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = AnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
# Or passing in an existing client
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
|
||||
)
|
||||
client = AnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
anthropic_client=anthropic_client,
|
||||
)
|
||||
|
||||
# Using custom ChatOptions with type safety:
|
||||
from typing import TypedDict
|
||||
from agent_framework.anthropic import AnthropicChatOptions
|
||||
|
||||
|
||||
class MyOptions(AnthropicChatOptions, total=False):
|
||||
my_custom_option: str
|
||||
|
||||
|
||||
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
|
||||
response = await client.get_response("Hello", options={"my_custom_option": "value"})
|
||||
"""
|
||||
super().__init__(
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
anthropic_client=anthropic_client,
|
||||
additional_beta_flags=additional_beta_flags,
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
env_file_path=env_file_path,
|
||||
env_file_encoding=env_file_encoding,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_anthropic"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_anthropic --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -6,15 +6,18 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import (
|
||||
ChatMiddlewareLayer,
|
||||
ChatOptions,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationLayer,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
tool,
|
||||
)
|
||||
from agent_framework._settings import load_settings
|
||||
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from anthropic.types.beta import (
|
||||
BetaMessage,
|
||||
BetaTextBlock,
|
||||
@@ -23,7 +26,7 @@ from anthropic.types.beta import (
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_anthropic import AnthropicClient
|
||||
from agent_framework_anthropic import AnthropicClient, RawAnthropicClient
|
||||
from agent_framework_anthropic._chat_client import AnthropicSettings
|
||||
|
||||
# Test constants
|
||||
@@ -64,6 +67,8 @@ def create_test_anthropic_client(
|
||||
client.additional_beta_flags = []
|
||||
client.chat_middleware = []
|
||||
client.function_middleware = []
|
||||
client._cached_chat_middleware_pipeline = None
|
||||
client._cached_function_middleware_pipeline = None
|
||||
client.function_invocation_configuration = normalize_function_invocation_configuration(None)
|
||||
|
||||
return client
|
||||
@@ -117,6 +122,19 @@ def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) ->
|
||||
assert isinstance(client, SupportsChatGetResponse)
|
||||
|
||||
|
||||
def test_anthropic_client_wraps_raw_client_with_standard_layer_order() -> None:
|
||||
"""Test AnthropicClient composes the standard public layer stack around the raw client."""
|
||||
assert issubclass(AnthropicClient, RawAnthropicClient)
|
||||
mro = AnthropicClient.__mro__
|
||||
assert mro.index(FunctionInvocationLayer) < mro.index(ChatMiddlewareLayer)
|
||||
assert mro.index(ChatMiddlewareLayer) < mro.index(ChatTelemetryLayer)
|
||||
assert mro.index(ChatTelemetryLayer) < mro.index(RawAnthropicClient)
|
||||
# RawAnthropicClient must not include the convenience layers
|
||||
assert not issubclass(RawAnthropicClient, FunctionInvocationLayer)
|
||||
assert not issubclass(RawAnthropicClient, ChatMiddlewareLayer)
|
||||
assert not issubclass(RawAnthropicClient, ChatTelemetryLayer)
|
||||
|
||||
|
||||
def test_anthropic_client_init_auto_create_client(
|
||||
anthropic_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
@@ -87,9 +87,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai_search"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai_search --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -206,8 +206,8 @@ AzureAIAgentOptionsT = TypeVar(
|
||||
|
||||
|
||||
class AzureAIAgentClient(
|
||||
ChatMiddlewareLayer[AzureAIAgentOptionsT],
|
||||
FunctionInvocationLayer[AzureAIAgentOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIAgentOptionsT],
|
||||
ChatTelemetryLayer[AzureAIAgentOptionsT],
|
||||
BaseChatClient[AzureAIAgentOptionsT],
|
||||
Generic[AzureAIAgentOptionsT],
|
||||
|
||||
@@ -97,9 +97,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
you should consider which additional layers to apply. There is a defined ordering that
|
||||
you should follow:
|
||||
|
||||
1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware
|
||||
2. **FunctionInvocationLayer** - Handles tool/function calling loop
|
||||
3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry
|
||||
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
|
||||
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
|
||||
|
||||
Use ``AzureAIClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
@@ -1214,8 +1214,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
|
||||
class AzureAIClient(
|
||||
ChatMiddlewareLayer[AzureAIClientOptionsT],
|
||||
FunctionInvocationLayer[AzureAIClientOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIClientOptionsT],
|
||||
ChatTelemetryLayer[AzureAIClientOptionsT],
|
||||
RawAzureAIClient[AzureAIClientOptionsT],
|
||||
Generic[AzureAIClientOptionsT],
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc4"
|
||||
version = "1.0.0rc5"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"aiohttp>=3.7.0,<4",
|
||||
@@ -85,11 +85,16 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_ai"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azure_ai --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
help = "Run the package integration test suite."
|
||||
cmd = """
|
||||
pytest --import-mode=importlib
|
||||
-n logical --dist worksteal
|
||||
|
||||
@@ -87,6 +87,8 @@ def create_test_azure_ai_chat_client(
|
||||
client.middleware = None
|
||||
client.chat_middleware = []
|
||||
client.function_middleware = []
|
||||
client._cached_chat_middleware_pipeline = None
|
||||
client._cached_function_middleware_pipeline = None
|
||||
client.otel_provider_name = "azure.ai"
|
||||
client.function_invocation_configuration = {
|
||||
"enabled": True,
|
||||
@@ -151,6 +153,10 @@ def test_azure_ai_chat_client_init_auto_create_client(
|
||||
chat_client.agent_name = None
|
||||
chat_client.additional_properties = {}
|
||||
chat_client.middleware = None
|
||||
chat_client.chat_middleware = []
|
||||
chat_client.function_middleware = []
|
||||
chat_client._cached_chat_middleware_pipeline = None
|
||||
chat_client._cached_function_middleware_pipeline = None
|
||||
|
||||
assert chat_client.agents_client is mock_agents_client
|
||||
assert chat_client.agent_id is None
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
@@ -84,10 +84,17 @@ exclude_dirs = ["tests"]
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
|
||||
test = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
|
||||
integration-tests = "pytest tests/test_cosmos_history_provider.py -m integration"
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azure_cosmos"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = "pytest -m \"not integration\" --cov=agent_framework_azure_cosmos --cov-report=term-missing:skip-covered tests"
|
||||
|
||||
[tool.poe.tasks.integration-tests]
|
||||
help = "Run the package integration test suite."
|
||||
cmd = "pytest tests/test_cosmos_history_provider.py -m integration"
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
@@ -91,9 +91,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_azurefunctions"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_azurefunctions --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -216,8 +216,8 @@ class BedrockSettings(TypedDict, total=False):
|
||||
|
||||
|
||||
class BedrockChatClient(
|
||||
ChatMiddlewareLayer[BedrockChatOptionsT],
|
||||
FunctionInvocationLayer[BedrockChatOptionsT],
|
||||
ChatMiddlewareLayer[BedrockChatOptionsT],
|
||||
ChatTelemetryLayer[BedrockChatOptionsT],
|
||||
BaseChatClient[BedrockChatOptionsT],
|
||||
Generic[BedrockChatOptionsT],
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
@@ -84,9 +84,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_bedrock"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_bedrock --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -86,9 +86,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_chatkit"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_chatkit --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
@@ -86,9 +86,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_claude"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_claude --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260311"
|
||||
version = "1.0.0b260319"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -23,7 +23,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
@@ -85,9 +85,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_copilotstudio"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_copilotstudio --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
|
||||
@@ -966,16 +966,7 @@ def _apply_get_response_docstrings() -> None:
|
||||
from .observability import ChatTelemetryLayer
|
||||
|
||||
apply_layered_docstring(ChatTelemetryLayer.get_response, BaseChatClient.get_response)
|
||||
apply_layered_docstring(
|
||||
FunctionInvocationLayer.get_response,
|
||||
ChatTelemetryLayer.get_response,
|
||||
extra_keyword_args={
|
||||
"function_middleware": """
|
||||
Optional per-call function middleware.
|
||||
When omitted, middleware configured on the client or forwarded from higher layers is used.
|
||||
""",
|
||||
},
|
||||
)
|
||||
apply_layered_docstring(FunctionInvocationLayer.get_response, ChatTelemetryLayer.get_response)
|
||||
apply_layered_docstring(
|
||||
ChatMiddlewareLayer.get_response,
|
||||
FunctionInvocationLayer.get_response,
|
||||
|
||||
@@ -902,13 +902,21 @@ class MCPTool:
|
||||
continue
|
||||
|
||||
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
|
||||
# Normalize inputSchema: ensure "properties" exists for object schemas.
|
||||
# Some MCP servers (e.g. zero-argument tools) omit "properties",
|
||||
# which causes OpenAI API to reject the schema with a 400 error.
|
||||
# Guard against non-conforming MCP servers that send inputSchema=None
|
||||
# despite the MCP spec typing it as dict[str, Any].
|
||||
input_schema = dict(tool.inputSchema or {})
|
||||
if input_schema.get("type") == "object" and "properties" not in input_schema:
|
||||
input_schema["properties"] = {}
|
||||
# Create FunctionTools out of each tool
|
||||
func: FunctionTool = FunctionTool(
|
||||
func=partial(self.call_tool, tool.name),
|
||||
name=local_name,
|
||||
description=tool.description or "",
|
||||
approval_mode=approval_mode,
|
||||
input_model=tool.inputSchema,
|
||||
input_model=input_schema,
|
||||
additional_properties={
|
||||
_MCP_REMOTE_NAME_KEY: tool.name,
|
||||
_MCP_NORMALIZED_NAME_KEY: normalized_name,
|
||||
|
||||
@@ -742,12 +742,17 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline):
|
||||
middleware: The list of agent middleware to include in the pipeline.
|
||||
"""
|
||||
super().__init__()
|
||||
self._source_middleware: tuple[AgentMiddlewareTypes, ...] = tuple(middleware)
|
||||
self._middleware: list[AgentMiddleware] = []
|
||||
|
||||
if middleware:
|
||||
for mdlware in middleware:
|
||||
self._register_middleware(mdlware)
|
||||
|
||||
def matches(self, middleware: Sequence[AgentMiddlewareTypes]) -> bool:
|
||||
"""Return whether this pipeline was built from the provided middleware sequence."""
|
||||
return self._source_middleware == tuple(middleware)
|
||||
|
||||
def _register_middleware(self, middleware: AgentMiddlewareTypes) -> None:
|
||||
"""Register an agent middleware item.
|
||||
|
||||
@@ -824,12 +829,17 @@ class FunctionMiddlewarePipeline(BaseMiddlewarePipeline):
|
||||
middleware: The list of function middleware to include in the pipeline.
|
||||
"""
|
||||
super().__init__()
|
||||
self._source_middleware: tuple[FunctionMiddlewareTypes, ...] = tuple(middleware)
|
||||
self._middleware: list[FunctionMiddleware] = []
|
||||
|
||||
if middleware:
|
||||
for mdlware in middleware:
|
||||
self._register_middleware(mdlware)
|
||||
|
||||
def matches(self, middleware: Sequence[FunctionMiddlewareTypes]) -> bool:
|
||||
"""Return whether this pipeline was built from the provided middleware sequence."""
|
||||
return self._source_middleware == tuple(middleware)
|
||||
|
||||
def _register_middleware(self, middleware: FunctionMiddlewareTypes) -> None:
|
||||
"""Register a function middleware item.
|
||||
|
||||
@@ -892,12 +902,17 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline):
|
||||
middleware: The list of chat middleware to include in the pipeline.
|
||||
"""
|
||||
super().__init__()
|
||||
self._source_middleware: tuple[ChatMiddlewareTypes, ...] = tuple(middleware)
|
||||
self._middleware: list[ChatMiddleware] = []
|
||||
|
||||
if middleware:
|
||||
for mdlware in middleware:
|
||||
self._register_middleware(mdlware)
|
||||
|
||||
def matches(self, middleware: Sequence[ChatMiddlewareTypes]) -> bool:
|
||||
"""Return whether this pipeline was built from the provided middleware sequence."""
|
||||
return self._source_middleware == tuple(middleware)
|
||||
|
||||
def _register_middleware(self, middleware: ChatMiddlewareTypes) -> None:
|
||||
"""Register a chat middleware item.
|
||||
|
||||
@@ -980,16 +995,26 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
middleware: Sequence[ChatMiddlewareTypes] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
middleware_list = categorize_middleware(*(middleware or []))
|
||||
self.chat_middleware = middleware_list["chat"]
|
||||
if "function_middleware" in kwargs and middleware_list["function"]:
|
||||
raise ValueError("Cannot specify 'function_middleware' and 'middleware' at the same time.")
|
||||
kwargs["function_middleware"] = middleware_list["function"]
|
||||
self.chat_middleware = list(middleware) if middleware else []
|
||||
self._cached_chat_middleware_pipeline: ChatMiddlewarePipeline | None = None
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _get_chat_middleware_pipeline(
|
||||
self,
|
||||
middleware: Sequence[ChatMiddlewareTypes],
|
||||
) -> ChatMiddlewarePipeline:
|
||||
effective_middleware = [*self.chat_middleware, *middleware]
|
||||
if self._cached_chat_middleware_pipeline is not None and self._cached_chat_middleware_pipeline.matches(
|
||||
effective_middleware
|
||||
):
|
||||
return self._cached_chat_middleware_pipeline
|
||||
|
||||
self._cached_chat_middleware_pipeline = ChatMiddlewarePipeline(*effective_middleware)
|
||||
return self._cached_chat_middleware_pipeline
|
||||
|
||||
@overload
|
||||
def get_response(
|
||||
self,
|
||||
@@ -1052,14 +1077,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
|
||||
kwargs["tokenizer"] = tokenizer
|
||||
|
||||
effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
|
||||
call_middleware = kwargs.pop("middleware", effective_client_kwargs.pop("middleware", []))
|
||||
middleware = categorize_middleware(call_middleware)
|
||||
effective_client_kwargs["function_middleware"] = middleware["function"]
|
||||
|
||||
pipeline = ChatMiddlewarePipeline(
|
||||
*self.chat_middleware,
|
||||
*middleware["chat"],
|
||||
)
|
||||
call_middleware = effective_client_kwargs.pop("middleware", [])
|
||||
pipeline = self._get_chat_middleware_pipeline(call_middleware) # type: ignore[reportUnknownArgumentType]
|
||||
if not pipeline.has_middlewares:
|
||||
return super_get_response( # type: ignore[no-any-return]
|
||||
messages=messages,
|
||||
@@ -1134,12 +1153,25 @@ class AgentMiddlewareLayer:
|
||||
) -> None:
|
||||
middleware_list = categorize_middleware(middleware)
|
||||
self.agent_middleware = middleware_list["agent"]
|
||||
self._cached_agent_middleware_pipeline: AgentMiddlewarePipeline | None = None
|
||||
# Pass middleware to super so BaseAgent can store it for dynamic rebuild
|
||||
super().__init__(*args, middleware=middleware, **kwargs) # type: ignore[call-arg]
|
||||
# Note: We intentionally don't extend client's middleware lists here.
|
||||
# Chat and function middleware is passed to the chat client at runtime via kwargs
|
||||
# in AgentMiddlewareLayer.run(), where it's properly combined with run-level middleware.
|
||||
|
||||
def _get_agent_middleware_pipeline(
|
||||
self,
|
||||
middleware: Sequence[AgentMiddlewareTypes],
|
||||
) -> AgentMiddlewarePipeline:
|
||||
if self._cached_agent_middleware_pipeline is not None and self._cached_agent_middleware_pipeline.matches(
|
||||
middleware
|
||||
):
|
||||
return self._cached_agent_middleware_pipeline
|
||||
|
||||
self._cached_agent_middleware_pipeline = AgentMiddlewarePipeline(*middleware)
|
||||
return self._cached_agent_middleware_pipeline
|
||||
|
||||
@overload
|
||||
def run(
|
||||
self,
|
||||
@@ -1210,7 +1242,7 @@ class AgentMiddlewareLayer:
|
||||
)
|
||||
base_middleware_list = categorize_middleware(base_middleware)
|
||||
run_middleware_list = categorize_middleware(middleware)
|
||||
pipeline = AgentMiddlewarePipeline(*base_middleware_list["agent"], *run_middleware_list["agent"])
|
||||
pipeline = self._get_agent_middleware_pipeline([*base_middleware_list["agent"], *run_middleware_list["agent"]])
|
||||
|
||||
# Combine base and run-level function/chat middleware for forwarding to chat client
|
||||
combined_function_chat_middleware = (
|
||||
@@ -1392,7 +1424,7 @@ def categorize_middleware(
|
||||
all_middleware: list[Any] = []
|
||||
for source in middleware_sources:
|
||||
if source:
|
||||
if isinstance(source, list):
|
||||
if isinstance(source, Sequence) and not isinstance(source, (str, bytes)):
|
||||
all_middleware.extend(source) # type: ignore
|
||||
else:
|
||||
all_middleware.append(source)
|
||||
|
||||
@@ -63,7 +63,12 @@ if TYPE_CHECKING:
|
||||
from ._clients import SupportsChatGetResponse
|
||||
from ._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from ._mcp import MCPTool
|
||||
from ._middleware import FunctionInvocationContext, FunctionMiddlewarePipeline, FunctionMiddlewareTypes
|
||||
from ._middleware import (
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
FunctionInvocationContext,
|
||||
FunctionMiddlewarePipeline,
|
||||
FunctionMiddlewareTypes,
|
||||
)
|
||||
from ._sessions import AgentSession
|
||||
from ._types import (
|
||||
ChatOptions,
|
||||
@@ -72,6 +77,7 @@ if TYPE_CHECKING:
|
||||
Content,
|
||||
Message,
|
||||
ResponseStream,
|
||||
UsageDetails,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -2023,18 +2029,37 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.function_middleware: list[FunctionMiddlewareTypes] = (
|
||||
list(function_middleware) if function_middleware else []
|
||||
)
|
||||
from ._middleware import categorize_middleware
|
||||
|
||||
middleware_list = categorize_middleware(middleware)
|
||||
self.function_middleware: list[FunctionMiddlewareTypes] = list(middleware_list["function"])
|
||||
self._cached_function_middleware_pipeline: FunctionMiddlewarePipeline | None = None
|
||||
self.function_invocation_configuration = normalize_function_invocation_configuration(
|
||||
function_invocation_configuration
|
||||
)
|
||||
if (chat_middleware := (middleware_list["chat"] or None)) is not None:
|
||||
kwargs["middleware"] = chat_middleware
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def _get_function_middleware_pipeline(
|
||||
self,
|
||||
middleware: Sequence[FunctionMiddlewareTypes],
|
||||
) -> FunctionMiddlewarePipeline:
|
||||
from ._middleware import FunctionMiddlewarePipeline
|
||||
|
||||
effective_middleware = [*self.function_middleware, *middleware]
|
||||
if self._cached_function_middleware_pipeline is not None and self._cached_function_middleware_pipeline.matches(
|
||||
effective_middleware
|
||||
):
|
||||
return self._cached_function_middleware_pipeline
|
||||
|
||||
self._cached_function_middleware_pipeline = FunctionMiddlewarePipeline(*effective_middleware)
|
||||
return self._cached_function_middleware_pipeline
|
||||
|
||||
@overload
|
||||
def get_response(
|
||||
self,
|
||||
@@ -2042,6 +2067,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
options: ChatOptions[ResponseModelBoundT],
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
@@ -2056,6 +2082,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
options: OptionsCoT | ChatOptions[None] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
@@ -2070,6 +2097,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
*,
|
||||
stream: Literal[True],
|
||||
options: OptionsCoT | ChatOptions[Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
@@ -2083,18 +2111,19 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: OptionsCoT | ChatOptions[Any] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
compaction_strategy: CompactionStrategy | None = None,
|
||||
tokenizer: TokenizerProtocol | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]:
|
||||
from ._middleware import FunctionMiddlewarePipeline
|
||||
from ._middleware import categorize_middleware
|
||||
from ._types import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
ResponseStream,
|
||||
add_usage_details,
|
||||
)
|
||||
|
||||
super_get_response = super().get_response # type: ignore[misc]
|
||||
@@ -2107,16 +2136,21 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
)
|
||||
|
||||
effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
|
||||
effective_function_middleware = function_middleware
|
||||
if effective_function_middleware is None:
|
||||
middleware_from_client_kwargs = effective_client_kwargs.pop("function_middleware", None)
|
||||
if middleware_from_client_kwargs is not None:
|
||||
effective_function_middleware = cast(Sequence[Any], middleware_from_client_kwargs)
|
||||
if middleware is not None:
|
||||
existing = effective_client_kwargs.get("middleware", [])
|
||||
effective_client_kwargs["middleware"] = [
|
||||
*(
|
||||
existing
|
||||
if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes))
|
||||
else [existing]
|
||||
),
|
||||
*middleware,
|
||||
]
|
||||
runtime_middleware = categorize_middleware(effective_client_kwargs.pop("middleware", []))
|
||||
|
||||
# ChatMiddleware adds this kwarg
|
||||
function_middleware_pipeline = FunctionMiddlewarePipeline(
|
||||
*(self.function_middleware), *(effective_function_middleware or [])
|
||||
)
|
||||
function_middleware_pipeline = self._get_function_middleware_pipeline(runtime_middleware["function"])
|
||||
if runtime_middleware["chat"]:
|
||||
effective_client_kwargs["middleware"] = runtime_middleware["chat"]
|
||||
max_errors = self.function_invocation_configuration.get(
|
||||
"max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST
|
||||
)
|
||||
@@ -2160,6 +2194,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
prepped_messages = list(messages)
|
||||
fcc_messages: list[Message] = []
|
||||
response: ChatResponse[Any] | None = None
|
||||
aggregated_usage: UsageDetails | None = None
|
||||
|
||||
loop_enabled = self.function_invocation_configuration.get("enabled", True)
|
||||
max_iterations = self.function_invocation_configuration.get("max_iterations", DEFAULT_MAX_ITERATIONS)
|
||||
@@ -2191,6 +2226,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
client_kwargs=filtered_kwargs,
|
||||
),
|
||||
)
|
||||
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
|
||||
|
||||
if response.conversation_id is not None:
|
||||
_update_conversation_id(kwargs, response.conversation_id, mutable_options)
|
||||
@@ -2207,6 +2243,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
execute_function_calls=execute_function_calls,
|
||||
)
|
||||
if result.get("action") == "return":
|
||||
response.usage_details = aggregated_usage
|
||||
return response
|
||||
total_function_calls += result.get("function_call_count", 0)
|
||||
if result.get("action") == "stop":
|
||||
@@ -2262,6 +2299,8 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
client_kwargs=filtered_kwargs,
|
||||
),
|
||||
)
|
||||
aggregated_usage = add_usage_details(aggregated_usage, response.usage_details)
|
||||
response.usage_details = aggregated_usage
|
||||
if fcc_messages:
|
||||
for msg in reversed(fcc_messages):
|
||||
response.messages.insert(0, msg)
|
||||
|
||||
@@ -109,7 +109,7 @@ class WorkflowViz:
|
||||
|
||||
# Create a temporary graphviz Source object
|
||||
dot_content = self.to_digraph(include_internal_executors=include_internal_executors)
|
||||
source = graphviz.Source(dot_content)
|
||||
source = graphviz.Source(dot_content) # type: ignore[reportUnknownVariableType]
|
||||
|
||||
try:
|
||||
if filename:
|
||||
@@ -131,7 +131,7 @@ class WorkflowViz:
|
||||
|
||||
source.render(base_name, format=format, cleanup=True) # type: ignore
|
||||
return f"{base_name}.{format}"
|
||||
except graphviz.backend.execute.ExecutableNotFound as e:
|
||||
except graphviz.backend.execute.ExecutableNotFound as e: # type: ignore
|
||||
raise ImportError(
|
||||
"The graphviz executables are not found. The graphviz Python package is installed, but the "
|
||||
"graphviz executables (dot, neato, etc.) are not available on your system's PATH. "
|
||||
|
||||
@@ -8,9 +8,6 @@ import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Generic, cast
|
||||
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework import (
|
||||
@@ -23,8 +20,7 @@ from agent_framework import (
|
||||
FunctionInvocationLayer,
|
||||
)
|
||||
from agent_framework.observability import ChatTelemetryLayer
|
||||
from agent_framework.openai import OpenAIChatOptions
|
||||
from agent_framework.openai._chat_client import RawOpenAIChatClient
|
||||
from agent_framework.openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
|
||||
|
||||
from .._settings import load_settings
|
||||
from ._entra_id_authentication import AzureCredentialTypes, AzureTokenProvider
|
||||
@@ -48,6 +44,10 @@ else:
|
||||
from typing_extensions import TypedDict # type: ignore # pragma: no cover
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.lib.azure import AsyncAzureOpenAI
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice
|
||||
|
||||
from agent_framework._middleware import MiddlewareTypes
|
||||
|
||||
logger: logging.Logger = logging.getLogger(__name__)
|
||||
@@ -152,8 +152,8 @@ AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAICha
|
||||
|
||||
class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
AzureOpenAIConfigMixin,
|
||||
ChatMiddlewareLayer[AzureOpenAIChatOptionsT],
|
||||
FunctionInvocationLayer[AzureOpenAIChatOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIChatOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIChatOptionsT],
|
||||
RawOpenAIChatClient[AzureOpenAIChatOptionsT],
|
||||
Generic[AzureOpenAIChatOptionsT],
|
||||
@@ -297,7 +297,9 @@ class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
For docs see:
|
||||
https://learn.microsoft.com/en-us/azure/ai-foundry/openai/references/on-your-data?tabs=python#context
|
||||
"""
|
||||
message = choice.message if isinstance(choice, Choice) else choice.delta
|
||||
message = getattr(choice, "message", None)
|
||||
if message is None:
|
||||
message = getattr(choice, "delta", None)
|
||||
# When you enable asynchronous content filtering in Azure OpenAI, you may receive empty deltas
|
||||
if message is None: # type: ignore
|
||||
return None
|
||||
|
||||
@@ -51,8 +51,8 @@ AzureOpenAIResponsesOptionsT = TypeVar(
|
||||
|
||||
class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
AzureOpenAIConfigMixin,
|
||||
ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT],
|
||||
FunctionInvocationLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIResponsesOptionsT],
|
||||
RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT],
|
||||
Generic[AzureOpenAIResponsesOptionsT],
|
||||
|
||||
@@ -362,11 +362,15 @@ def _create_otlp_exporters(
|
||||
if protocol == "grpc":
|
||||
# Import all gRPC exporters
|
||||
try:
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
||||
OTLPMetricExporter as GRPCMetricExporter,
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # type: ignore[reportMissingImports]
|
||||
OTLPLogExporter as GRPCLogExporter, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # type: ignore[reportMissingImports]
|
||||
OTLPMetricExporter as GRPCMetricExporter, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[reportMissingImports]
|
||||
OTLPSpanExporter as GRPCSpanExporter, # type: ignore[reportUnknownVariableType]
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCSpanExporter
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"opentelemetry-exporter-otlp-proto-grpc is required for OTLP gRPC exporters. "
|
||||
@@ -375,21 +379,21 @@ def _create_otlp_exporters(
|
||||
|
||||
if actual_logs_endpoint:
|
||||
exporters.append(
|
||||
GRPCLogExporter(
|
||||
GRPCLogExporter( # type: ignore[reportUnknownArgumentType]
|
||||
endpoint=actual_logs_endpoint,
|
||||
headers=actual_logs_headers if actual_logs_headers else None,
|
||||
)
|
||||
)
|
||||
if actual_traces_endpoint:
|
||||
exporters.append(
|
||||
GRPCSpanExporter(
|
||||
GRPCSpanExporter( # type: ignore[reportUnknownArgumentType]
|
||||
endpoint=actual_traces_endpoint,
|
||||
headers=actual_traces_headers if actual_traces_headers else None,
|
||||
)
|
||||
)
|
||||
if actual_metrics_endpoint:
|
||||
exporters.append(
|
||||
GRPCMetricExporter(
|
||||
GRPCMetricExporter( # type: ignore[reportUnknownArgumentType]
|
||||
endpoint=actual_metrics_endpoint,
|
||||
headers=actual_metrics_headers if actual_metrics_headers else None,
|
||||
)
|
||||
@@ -899,6 +903,25 @@ def get_meter(
|
||||
OBSERVABILITY_SETTINGS: ObservabilitySettings = ObservabilitySettings()
|
||||
|
||||
|
||||
def _read_bool_env(name: str, *, default: bool = False) -> bool:
|
||||
"""Read a boolean from an environment variable."""
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
def _read_int_env(name: str, *, default: int | None = None) -> int | None:
|
||||
"""Read an optional integer from an environment variable."""
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def enable_instrumentation(
|
||||
*,
|
||||
enable_sensitive_data: bool | None = None,
|
||||
@@ -920,11 +943,15 @@ def enable_instrumentation(
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
if enable_sensitive_data is not None:
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
|
||||
else:
|
||||
# Re-read from current environment in case env vars were set after import (e.g. load_dotenv())
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = _read_bool_env("ENABLE_SENSITIVE_DATA")
|
||||
|
||||
|
||||
def configure_otel_providers(
|
||||
*,
|
||||
enable_sensitive_data: bool | None = None,
|
||||
enable_console_exporters: bool | None = None,
|
||||
exporters: list[LogRecordExporter | SpanExporter | MetricExporter] | None = None,
|
||||
views: list[View] | None = None,
|
||||
vs_code_extension_port: int | None = None,
|
||||
@@ -963,6 +990,8 @@ def configure_otel_providers(
|
||||
Keyword Args:
|
||||
enable_sensitive_data: Enable OpenTelemetry sensitive events. Overrides
|
||||
the environment variable ENABLE_SENSITIVE_DATA if set. Default is None.
|
||||
enable_console_exporters: Enable console exporters for traces, logs, and metrics.
|
||||
Overrides the environment variable ENABLE_CONSOLE_EXPORTERS if set. Default is None.
|
||||
exporters: A list of custom exporters for logs, metrics or spans, or any combination.
|
||||
These will be added in addition to exporters configured via environment variables.
|
||||
Default is None.
|
||||
@@ -1051,6 +1080,8 @@ def configure_otel_providers(
|
||||
settings_kwargs["env_file_encoding"] = env_file_encoding
|
||||
if enable_sensitive_data is not None:
|
||||
settings_kwargs["enable_sensitive_data"] = enable_sensitive_data
|
||||
if enable_console_exporters is not None:
|
||||
settings_kwargs["enable_console_exporters"] = enable_console_exporters
|
||||
if vs_code_extension_port is not None:
|
||||
settings_kwargs["vs_code_extension_port"] = vs_code_extension_port
|
||||
|
||||
@@ -1064,12 +1095,22 @@ def configure_otel_providers(
|
||||
OBSERVABILITY_SETTINGS._resource = updated_settings._resource # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage]
|
||||
else:
|
||||
# Update the observability settings with the provided values
|
||||
# Re-read settings from current environment in case env vars were set
|
||||
# after import (e.g. via load_dotenv()). Explicit parameters take precedence.
|
||||
OBSERVABILITY_SETTINGS.enable_instrumentation = True
|
||||
if enable_sensitive_data is not None:
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = enable_sensitive_data
|
||||
if vs_code_extension_port is not None:
|
||||
OBSERVABILITY_SETTINGS.vs_code_extension_port = vs_code_extension_port
|
||||
OBSERVABILITY_SETTINGS.enable_sensitive_data = (
|
||||
enable_sensitive_data if enable_sensitive_data is not None else _read_bool_env("ENABLE_SENSITIVE_DATA")
|
||||
)
|
||||
OBSERVABILITY_SETTINGS.enable_console_exporters = (
|
||||
enable_console_exporters
|
||||
if enable_console_exporters is not None
|
||||
else _read_bool_env("ENABLE_CONSOLE_EXPORTERS")
|
||||
)
|
||||
OBSERVABILITY_SETTINGS.vs_code_extension_port = (
|
||||
vs_code_extension_port if vs_code_extension_port is not None else _read_int_env("VS_CODE_EXTENSION_PORT")
|
||||
)
|
||||
OBSERVABILITY_SETTINGS._resource = create_resource() # type: ignore[reportPrivateUsage]
|
||||
OBSERVABILITY_SETTINGS._executed_setup = False # type: ignore[reportPrivateUsage]
|
||||
|
||||
OBSERVABILITY_SETTINGS._configure( # type: ignore[reportPrivateUsage]
|
||||
additional_exporters=exporters,
|
||||
|
||||
@@ -210,8 +210,8 @@ OpenAIAssistantsOptionsT = TypeVar(
|
||||
|
||||
class OpenAIAssistantsClient( # type: ignore[misc]
|
||||
OpenAIConfigMixin,
|
||||
ChatMiddlewareLayer[OpenAIAssistantsOptionsT],
|
||||
FunctionInvocationLayer[OpenAIAssistantsOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIAssistantsOptionsT],
|
||||
ChatTelemetryLayer[OpenAIAssistantsOptionsT],
|
||||
BaseChatClient[OpenAIAssistantsOptionsT],
|
||||
Generic[OpenAIAssistantsOptionsT],
|
||||
|
||||
@@ -31,7 +31,7 @@ from pydantic import BaseModel
|
||||
|
||||
from .._clients import BaseChatClient
|
||||
from .._docstrings import apply_layered_docstring
|
||||
from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionMiddlewareTypes
|
||||
from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer
|
||||
from .._settings import load_settings
|
||||
from .._tools import (
|
||||
FunctionInvocationConfiguration,
|
||||
@@ -156,9 +156,9 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
you should consider which additional layers to apply. There is a defined ordering that
|
||||
you should follow:
|
||||
|
||||
1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware
|
||||
2. **FunctionInvocationLayer** - Handles tool/function calling loop
|
||||
3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry
|
||||
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
|
||||
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
|
||||
|
||||
Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
@@ -713,9 +713,13 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
"content": content.result if content.result is not None else "",
|
||||
}
|
||||
case "data" | "uri" if content.has_top_level_media_type("image"):
|
||||
image_url_obj: dict[str, Any] = {"url": content.uri}
|
||||
detail = content.additional_properties.get("detail")
|
||||
if isinstance(detail, str):
|
||||
image_url_obj["detail"] = detail
|
||||
return {
|
||||
"type": "image_url",
|
||||
"image_url": {"url": content.uri},
|
||||
"image_url": image_url_obj,
|
||||
}
|
||||
case "data" | "uri" if content.has_top_level_media_type("audio"):
|
||||
if content.media_type and "wav" in content.media_type:
|
||||
@@ -772,8 +776,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
|
||||
class OpenAIChatClient( # type: ignore[misc]
|
||||
OpenAIConfigMixin,
|
||||
ChatMiddlewareLayer[OpenAIChatOptionsT],
|
||||
FunctionInvocationLayer[OpenAIChatOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIChatOptionsT],
|
||||
ChatTelemetryLayer[OpenAIChatOptionsT],
|
||||
RawOpenAIChatClient[OpenAIChatOptionsT],
|
||||
Generic[OpenAIChatOptionsT],
|
||||
@@ -787,7 +791,6 @@ class OpenAIChatClient( # type: ignore[misc]
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
options: ChatOptions[ResponseModelBoundT],
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
@@ -801,7 +804,6 @@ class OpenAIChatClient( # type: ignore[misc]
|
||||
*,
|
||||
stream: Literal[False] = ...,
|
||||
options: OpenAIChatOptionsT | ChatOptions[None] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
@@ -815,7 +817,6 @@ class OpenAIChatClient( # type: ignore[misc]
|
||||
*,
|
||||
stream: Literal[True],
|
||||
options: OpenAIChatOptionsT | ChatOptions[Any] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
@@ -829,7 +830,6 @@ class OpenAIChatClient( # type: ignore[misc]
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: OpenAIChatOptionsT | ChatOptions[Any] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
@@ -840,14 +840,15 @@ class OpenAIChatClient( # type: ignore[misc]
|
||||
"Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]",
|
||||
super().get_response, # type: ignore[misc]
|
||||
)
|
||||
effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
|
||||
if middleware is not None:
|
||||
effective_client_kwargs["middleware"] = middleware
|
||||
return super_get_response( # type: ignore[no-any-return]
|
||||
messages=messages,
|
||||
stream=stream,
|
||||
options=options,
|
||||
function_middleware=function_middleware,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
middleware=middleware,
|
||||
client_kwargs=effective_client_kwargs,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -963,10 +964,6 @@ def _apply_openai_chat_client_docstrings() -> None:
|
||||
OpenAIChatClient.get_response,
|
||||
RawOpenAIChatClient.get_response,
|
||||
extra_keyword_args={
|
||||
"function_middleware": """
|
||||
Optional per-call function middleware.
|
||||
When omitted, middleware configured on the client or forwarded from higher layers is used.
|
||||
""",
|
||||
"middleware": """
|
||||
Optional per-call chat and function middleware.
|
||||
This is merged with any middleware configured on the client for the current request.
|
||||
|
||||
@@ -249,9 +249,9 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
you should consider which additional layers to apply. There is a defined ordering that
|
||||
you should follow:
|
||||
|
||||
1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware
|
||||
2. **FunctionInvocationLayer** - Handles tool/function calling loop
|
||||
3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry
|
||||
1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware
|
||||
2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry
|
||||
|
||||
Use ``OpenAIResponsesClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
@@ -2259,8 +2259,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc]
|
||||
|
||||
class OpenAIResponsesClient( # type: ignore[misc]
|
||||
OpenAIConfigMixin,
|
||||
ChatMiddlewareLayer[OpenAIResponsesOptionsT],
|
||||
FunctionInvocationLayer[OpenAIResponsesOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIResponsesOptionsT],
|
||||
ChatTelemetryLayer[OpenAIResponsesOptionsT],
|
||||
RawOpenAIResponsesClient[OpenAIResponsesOptionsT],
|
||||
Generic[OpenAIResponsesOptionsT],
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0rc4"
|
||||
version = "1.0.0rc5"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -121,9 +121,13 @@ exclude_dirs = ["tests"]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks]
|
||||
mypy = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework"
|
||||
test = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
[tool.poe.tasks.mypy]
|
||||
help = "Run MyPy for this package."
|
||||
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework --cov-report=term-missing:skip-covered -n auto --dist worksteal tests'
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework"
|
||||
|
||||
@@ -652,6 +652,88 @@ async def test_streaming_with_none_delta(
|
||||
assert any(msg.contents for msg in results)
|
||||
|
||||
|
||||
# region _parse_text_from_openai direct unit tests
|
||||
|
||||
|
||||
def test_parse_text_from_openai_with_choice_message(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai correctly reads message from a Choice."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(content="hello", role="assistant"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "hello"
|
||||
|
||||
|
||||
def test_parse_text_from_openai_with_chunk_choice_delta(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai correctly reads delta from a ChunkChoice."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = ChunkChoice(
|
||||
index=0,
|
||||
delta=ChunkChoiceDelta(content="streamed", role="assistant"),
|
||||
finish_reason=None,
|
||||
)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "streamed"
|
||||
|
||||
|
||||
def test_parse_text_from_openai_refusal_choice(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai returns refusal text from a Choice."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(content=None, role="assistant", refusal="I cannot help with that"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "I cannot help with that"
|
||||
|
||||
|
||||
def test_parse_text_from_openai_refusal_chunk_choice(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai returns refusal text from a ChunkChoice."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = ChunkChoice(
|
||||
index=0,
|
||||
delta=ChunkChoiceDelta(content=None, role="assistant", refusal="I cannot help with that"),
|
||||
finish_reason=None,
|
||||
)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is not None
|
||||
assert result.type == "text"
|
||||
assert result.text == "I cannot help with that"
|
||||
|
||||
|
||||
def test_parse_text_from_openai_no_content_no_refusal(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai returns None when no content or refusal."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = Choice(
|
||||
index=0,
|
||||
message=ChatCompletionMessage(content=None, role="assistant"),
|
||||
finish_reason="stop",
|
||||
)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_parse_text_from_openai_none_delta(azure_openai_unit_test_env: dict[str, str]) -> None:
|
||||
"""Test _parse_text_from_openai returns None when delta is None (async content filtering)."""
|
||||
client = AzureOpenAIChatClient()
|
||||
choice = ChunkChoice.model_construct(index=0, delta=None, finish_reason=None)
|
||||
result = client._parse_text_from_openai(choice)
|
||||
assert result is None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
@patch.object(AsyncChatCompletions, "create", new_callable=AsyncMock)
|
||||
async def test_cmc_with_conversation_id(
|
||||
mock_create: AsyncMock,
|
||||
|
||||
@@ -128,8 +128,8 @@ class MockChatClient:
|
||||
|
||||
|
||||
class MockBaseChatClient(
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
FunctionInvocationLayer[OptionsCoT],
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
ChatTelemetryLayer[OptionsCoT],
|
||||
BaseChatClient[OptionsCoT],
|
||||
Generic[OptionsCoT],
|
||||
@@ -137,7 +137,7 @@ class MockBaseChatClient(
|
||||
"""Mock implementation of a full-featured ChatClient."""
|
||||
|
||||
def __init__(self, **kwargs: Any):
|
||||
super().__init__(function_middleware=[], **kwargs)
|
||||
super().__init__(middleware=[], **kwargs)
|
||||
self.run_responses: list[ChatResponse] = []
|
||||
self.streaming_responses: list[list[ChatResponseUpdate]] = []
|
||||
self.call_count: int = 0
|
||||
|
||||
@@ -74,8 +74,8 @@ def test_openai_chat_client_get_response_docstring_surfaces_layered_runtime_docs
|
||||
assert docstring is not None
|
||||
assert "Get a response from a chat client." in docstring
|
||||
assert "function_invocation_kwargs" in docstring
|
||||
assert "function_middleware: Optional per-call function middleware." in docstring
|
||||
assert "middleware: Optional per-call chat and function middleware." in docstring
|
||||
assert "function_middleware: Optional per-call function middleware." not in docstring
|
||||
|
||||
|
||||
def test_openai_chat_client_get_response_is_defined_on_openai_class() -> None:
|
||||
@@ -84,7 +84,6 @@ def test_openai_chat_client_get_response_is_defined_on_openai_class() -> None:
|
||||
signature = inspect.signature(OpenAIChatClient.get_response)
|
||||
|
||||
assert OpenAIChatClient.get_response.__qualname__ == "OpenAIChatClient.get_response"
|
||||
assert "function_middleware" in signature.parameters
|
||||
assert "middleware" in signature.parameters
|
||||
|
||||
|
||||
|
||||
@@ -3226,7 +3226,7 @@ async def test_terminate_loop_single_function_call(chat_client_base: SupportsCha
|
||||
response = await chat_client_base.get_response(
|
||||
"hello",
|
||||
options={"tool_choice": "auto", "tools": [ai_func]},
|
||||
middleware=[TerminateLoopMiddleware()],
|
||||
client_kwargs={"middleware": [TerminateLoopMiddleware()]},
|
||||
)
|
||||
|
||||
# Function should NOT have been executed - middleware intercepted it
|
||||
@@ -3292,7 +3292,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client
|
||||
response = await chat_client_base.get_response(
|
||||
"hello",
|
||||
options={"tool_choice": "auto", "tools": [normal_func, terminating_func]},
|
||||
middleware=[SelectiveTerminateMiddleware()],
|
||||
client_kwargs={"middleware": [SelectiveTerminateMiddleware()]},
|
||||
)
|
||||
|
||||
# normal_function should have executed (middleware calls next_handler)
|
||||
@@ -3345,7 +3345,7 @@ async def test_terminate_loop_streaming_single_function_call(chat_client_base: S
|
||||
async for update in chat_client_base.get_response(
|
||||
"hello",
|
||||
options={"tool_choice": "auto", "tools": [ai_func]},
|
||||
middleware=[TerminateLoopMiddleware()],
|
||||
client_kwargs={"middleware": [TerminateLoopMiddleware()]},
|
||||
stream=True,
|
||||
):
|
||||
updates.append(update)
|
||||
@@ -3389,12 +3389,12 @@ async def test_conversation_id_updated_in_options_between_tool_iterations():
|
||||
conversation_ids_received: list[str | None] = []
|
||||
|
||||
class TrackingChatClient(
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationLayer,
|
||||
ChatMiddlewareLayer,
|
||||
BaseChatClient,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(function_middleware=[])
|
||||
super().__init__(middleware=[])
|
||||
self.run_responses: list[ChatResponse] = []
|
||||
self.streaming_responses: list[list[ChatResponseUpdate]] = []
|
||||
self.call_count: int = 0
|
||||
|
||||
@@ -84,8 +84,8 @@ class _MockBaseChatClient(BaseChatClient[Any]):
|
||||
|
||||
|
||||
class FunctionInvokingMockClient(
|
||||
ChatMiddlewareLayer[Any],
|
||||
FunctionInvocationLayer[Any],
|
||||
ChatMiddlewareLayer[Any],
|
||||
ChatTelemetryLayer[Any],
|
||||
_MockBaseChatClient,
|
||||
):
|
||||
|
||||
@@ -2042,6 +2042,100 @@ async def test_load_tools_with_pagination():
|
||||
assert [f.name for f in tool._functions] == ["tool_1", "tool_2", "tool_3", "tool_4"]
|
||||
|
||||
|
||||
async def test_load_tools_adds_properties_to_zero_arg_tool_schema():
|
||||
"""Test that load_tools normalizes inputSchema for zero-argument MCP tools.
|
||||
|
||||
Some MCP servers (e.g. matlab-mcp-core-server) declare zero-argument tools
|
||||
with inputSchema={"type": "object"} and no "properties" key. OpenAI's API
|
||||
requires "properties" to be present on object schemas, so load_tools must
|
||||
inject an empty "properties" dict when it is missing.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from agent_framework._mcp import MCPTool
|
||||
|
||||
tool = MCPTool(name="test_tool")
|
||||
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
original_zero_arg_schema = {"type": "object"}
|
||||
original_string_schema = {"type": "string"}
|
||||
original_empty_schema: dict[str, object] = {}
|
||||
|
||||
page = MagicMock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="zero_arg_tool",
|
||||
description="A tool with no parameters",
|
||||
inputSchema=original_zero_arg_schema,
|
||||
),
|
||||
types.Tool(
|
||||
name="normal_tool",
|
||||
description="A tool with parameters",
|
||||
inputSchema={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
),
|
||||
types.Tool(
|
||||
name="string_schema_tool",
|
||||
description="A tool with a non-object schema",
|
||||
inputSchema=original_string_schema,
|
||||
),
|
||||
types.Tool(
|
||||
name="empty_schema_tool",
|
||||
description="A tool with an empty schema",
|
||||
inputSchema=original_empty_schema,
|
||||
),
|
||||
]
|
||||
|
||||
# Simulate a non-conforming MCP server that sends inputSchema=None.
|
||||
# types.Tool requires inputSchema to be a dict, so we use a MagicMock.
|
||||
none_schema_tool = MagicMock()
|
||||
none_schema_tool.name = "none_schema_tool"
|
||||
none_schema_tool.description = "A tool with None inputSchema"
|
||||
none_schema_tool.inputSchema = None
|
||||
page.tools.append(none_schema_tool)
|
||||
page.nextCursor = None
|
||||
|
||||
mock_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
await tool.load_tools()
|
||||
|
||||
assert len(tool._functions) == 5
|
||||
|
||||
funcs_by_name = {f.name: f for f in tool._functions}
|
||||
|
||||
# Zero-arg tool must have "properties" injected
|
||||
zero_params = funcs_by_name["zero_arg_tool"].parameters()
|
||||
assert "properties" in zero_params
|
||||
assert zero_params["properties"] == {}
|
||||
assert zero_params["type"] == "object"
|
||||
|
||||
# Normal tool must retain its existing properties
|
||||
normal_params = funcs_by_name["normal_tool"].parameters()
|
||||
assert "properties" in normal_params
|
||||
assert "x" in normal_params["properties"]
|
||||
assert normal_params["required"] == ["x"]
|
||||
|
||||
# Non-object schema must NOT have "properties" injected
|
||||
string_params = funcs_by_name["string_schema_tool"].parameters()
|
||||
assert "properties" not in string_params
|
||||
assert string_params["type"] == "string"
|
||||
|
||||
# Empty schema (no "type" key) must NOT have "properties" injected
|
||||
empty_params = funcs_by_name["empty_schema_tool"].parameters()
|
||||
assert "properties" not in empty_params
|
||||
|
||||
# None inputSchema must produce an empty dict (guard against non-conforming servers)
|
||||
none_params = funcs_by_name["none_schema_tool"].parameters()
|
||||
assert none_params == {}
|
||||
|
||||
# Original inputSchema dicts must not be mutated
|
||||
assert "properties" not in original_zero_arg_schema
|
||||
assert "properties" not in original_string_schema
|
||||
assert "properties" not in original_empty_schema
|
||||
|
||||
|
||||
async def test_load_prompts_with_pagination():
|
||||
"""Test that load_prompts handles pagination correctly."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
@@ -28,6 +28,7 @@ from agent_framework._middleware import (
|
||||
FunctionMiddleware,
|
||||
FunctionMiddlewarePipeline,
|
||||
MiddlewareTermination,
|
||||
categorize_middleware,
|
||||
)
|
||||
from agent_framework._tools import FunctionTool
|
||||
|
||||
@@ -1681,3 +1682,49 @@ def mock_chat_client() -> Any:
|
||||
client = MagicMock(spec=SupportsChatGetResponse)
|
||||
client.service_url = MagicMock(return_value="mock://test")
|
||||
return client
|
||||
|
||||
|
||||
class TestCategorizeMiddleware:
|
||||
"""Test cases for categorize_middleware."""
|
||||
|
||||
def test_categorize_middleware_with_tuple(self) -> None:
|
||||
"""Test that tuple middleware sources are unpacked, not appended as a single item."""
|
||||
chat_mw = TestChatMiddleware()
|
||||
function_mw = TestFunctionMiddleware()
|
||||
agent_mw = TestAgentMiddleware()
|
||||
result = categorize_middleware((chat_mw, function_mw, agent_mw))
|
||||
assert result["chat"] == [chat_mw]
|
||||
assert result["function"] == [function_mw]
|
||||
assert result["agent"] == [agent_mw]
|
||||
|
||||
def test_categorize_middleware_with_list(self) -> None:
|
||||
"""Test that list middleware sources are unpacked correctly."""
|
||||
chat_mw = TestChatMiddleware()
|
||||
function_mw = TestFunctionMiddleware()
|
||||
result = categorize_middleware([chat_mw, function_mw])
|
||||
assert result["chat"] == [chat_mw]
|
||||
assert result["function"] == [function_mw]
|
||||
assert result["agent"] == []
|
||||
|
||||
def test_categorize_middleware_with_none(self) -> None:
|
||||
"""Test that None middleware sources are handled."""
|
||||
result = categorize_middleware(None)
|
||||
assert result["chat"] == []
|
||||
assert result["function"] == []
|
||||
assert result["agent"] == []
|
||||
|
||||
def test_categorize_middleware_with_single_item(self) -> None:
|
||||
"""Test that a single unwrapped middleware item is appended correctly."""
|
||||
chat_mw = TestChatMiddleware()
|
||||
result = categorize_middleware(chat_mw)
|
||||
assert result["chat"] == [chat_mw]
|
||||
assert result["function"] == []
|
||||
assert result["agent"] == []
|
||||
|
||||
def test_categorize_middleware_with_string_does_not_decompose(self) -> None:
|
||||
"""Test that a string is not decomposed character-by-character."""
|
||||
result = categorize_middleware("not_a_middleware")
|
||||
# String should be treated as a single item, not decomposed into characters
|
||||
total_items = len(result["chat"]) + len(result["function"]) + len(result["agent"])
|
||||
assert total_items == 1
|
||||
assert result["agent"] == ["not_a_middleware"]
|
||||
|
||||
@@ -697,6 +697,26 @@ class TestChatAgentFunctionMiddlewareWithTools:
|
||||
assert function_calls[0].name == "sample_tool_function"
|
||||
assert function_results[0].call_id == function_calls[0].call_id
|
||||
|
||||
def test_agent_middleware_pipeline_cache_reuses_matching_middleware(self) -> None:
|
||||
"""Test that identical agent middleware sets reuse the cached pipeline."""
|
||||
|
||||
@agent_middleware
|
||||
async def first_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
@agent_middleware
|
||||
async def second_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
agent = Agent(client=MockBaseChatClient())
|
||||
|
||||
first_pipeline = agent._get_agent_middleware_pipeline([first_middleware])
|
||||
second_pipeline = agent._get_agent_middleware_pipeline([first_middleware])
|
||||
third_pipeline = agent._get_agent_middleware_pipeline([second_middleware])
|
||||
|
||||
assert first_pipeline is second_pipeline
|
||||
assert third_pipeline is not first_pipeline
|
||||
|
||||
async def test_function_middleware_can_access_and_override_custom_kwargs(
|
||||
self, chat_client_base: "MockBaseChatClient"
|
||||
) -> None:
|
||||
@@ -1969,6 +1989,77 @@ class TestChatAgentChatMiddleware:
|
||||
"agent_middleware_after",
|
||||
]
|
||||
|
||||
async def test_combined_middleware_with_tool_loop(self) -> None:
|
||||
"""Test Agent middleware ordering when tool calls trigger multiple chat rounds."""
|
||||
execution_order: list[str] = []
|
||||
chat_round = 0
|
||||
client = MockBaseChatClient()
|
||||
client.run_responses = [
|
||||
ChatResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="call_123",
|
||||
name="sample_tool_function",
|
||||
arguments='{"location": "Seattle"}',
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
),
|
||||
ChatResponse(messages=[Message(role="assistant", text="Final response")]),
|
||||
]
|
||||
|
||||
async def tracking_agent_middleware(
|
||||
context: AgentContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
execution_order.append("agent_middleware_before")
|
||||
await call_next()
|
||||
execution_order.append("agent_middleware_after")
|
||||
|
||||
async def tracking_chat_middleware(
|
||||
context: ChatContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
nonlocal chat_round
|
||||
chat_round += 1
|
||||
execution_order.append(f"chat_middleware_before_{chat_round}")
|
||||
await call_next()
|
||||
execution_order.append(f"chat_middleware_after_{chat_round}")
|
||||
|
||||
async def tracking_function_middleware(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
execution_order.append("function_middleware_before")
|
||||
await call_next()
|
||||
execution_order.append("function_middleware_after")
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
middleware=[tracking_chat_middleware, tracking_function_middleware, tracking_agent_middleware],
|
||||
tools=[sample_tool_function],
|
||||
)
|
||||
|
||||
response = await agent.run([Message(role="user", text="test")])
|
||||
|
||||
assert response is not None
|
||||
assert client.call_count == 2
|
||||
assert response.messages[-1].text == "Final response"
|
||||
assert execution_order == [
|
||||
"agent_middleware_before",
|
||||
"chat_middleware_before_1",
|
||||
"chat_middleware_after_1",
|
||||
"function_middleware_before",
|
||||
"function_middleware_after",
|
||||
"chat_middleware_before_2",
|
||||
"chat_middleware_after_2",
|
||||
"agent_middleware_after",
|
||||
]
|
||||
|
||||
async def test_agent_middleware_can_access_and_override_custom_kwargs(self) -> None:
|
||||
"""Test that agent middleware can access and override custom parameters like temperature."""
|
||||
captured_kwargs: dict[str, Any] = {}
|
||||
|
||||
@@ -274,7 +274,10 @@ class TestChatMiddleware:
|
||||
|
||||
# First call with run-level middleware
|
||||
messages = [Message(role="user", text="first message")]
|
||||
response1 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
|
||||
response1 = await chat_client_base.get_response(
|
||||
messages,
|
||||
client_kwargs={"middleware": [counting_middleware]},
|
||||
)
|
||||
assert response1 is not None
|
||||
assert execution_count["count"] == 1
|
||||
|
||||
@@ -286,7 +289,10 @@ class TestChatMiddleware:
|
||||
|
||||
# Third call with run-level middleware again - should execute
|
||||
messages = [Message(role="user", text="third message")]
|
||||
response3 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
|
||||
response3 = await chat_client_base.get_response(
|
||||
messages,
|
||||
client_kwargs={"middleware": [counting_middleware]},
|
||||
)
|
||||
assert response3 is not None
|
||||
assert execution_count["count"] == 2 # Should be 2 now
|
||||
|
||||
@@ -335,6 +341,81 @@ class TestChatMiddleware:
|
||||
assert modified_kwargs["new_param"] == "added_by_middleware"
|
||||
assert modified_kwargs["custom_param"] == "test_value" # Should still be there
|
||||
|
||||
def test_chat_middleware_pipeline_cache_reuses_matching_middleware(
|
||||
self,
|
||||
chat_client_base: "MockBaseChatClient",
|
||||
) -> None:
|
||||
"""Test that identical chat middleware sets reuse the cached pipeline."""
|
||||
|
||||
@chat_middleware
|
||||
async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
@chat_middleware
|
||||
async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
first_pipeline = chat_client_base._get_chat_middleware_pipeline([first_middleware])
|
||||
second_pipeline = chat_client_base._get_chat_middleware_pipeline([first_middleware])
|
||||
third_pipeline = chat_client_base._get_chat_middleware_pipeline([second_middleware])
|
||||
|
||||
assert first_pipeline is second_pipeline
|
||||
assert third_pipeline is not first_pipeline
|
||||
|
||||
def test_chat_middleware_pipeline_cache_includes_base_middleware(
|
||||
self,
|
||||
chat_client_base: "MockBaseChatClient",
|
||||
) -> None:
|
||||
"""Test that chat middleware cache key includes base middleware to prevent incorrect reuse."""
|
||||
|
||||
@chat_middleware
|
||||
async def base_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
@chat_middleware
|
||||
async def runtime_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
# Without base middleware
|
||||
pipeline_no_base = chat_client_base._get_chat_middleware_pipeline([runtime_middleware])
|
||||
|
||||
# With base middleware
|
||||
chat_client_base.chat_middleware = [base_middleware]
|
||||
pipeline_with_base = chat_client_base._get_chat_middleware_pipeline([runtime_middleware])
|
||||
|
||||
assert pipeline_with_base is not pipeline_no_base
|
||||
|
||||
def test_function_middleware_pipeline_cache_reuses_matching_middleware(
|
||||
self,
|
||||
chat_client_base: "MockBaseChatClient",
|
||||
) -> None:
|
||||
"""Test that identical function middleware sets reuse the cached pipeline."""
|
||||
|
||||
@function_middleware
|
||||
async def base_middleware(context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None:
|
||||
await call_next()
|
||||
|
||||
@function_middleware
|
||||
async def first_runtime_middleware(
|
||||
context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]
|
||||
) -> None:
|
||||
await call_next()
|
||||
|
||||
@function_middleware
|
||||
async def second_runtime_middleware(
|
||||
context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]
|
||||
) -> None:
|
||||
await call_next()
|
||||
|
||||
chat_client_base.function_middleware = [base_middleware]
|
||||
|
||||
first_pipeline = chat_client_base._get_function_middleware_pipeline([first_runtime_middleware])
|
||||
second_pipeline = chat_client_base._get_function_middleware_pipeline([first_runtime_middleware])
|
||||
third_pipeline = chat_client_base._get_function_middleware_pipeline([second_runtime_middleware])
|
||||
|
||||
assert first_pipeline is second_pipeline
|
||||
assert third_pipeline is not first_pipeline
|
||||
|
||||
async def test_function_middleware_registration_on_chat_client(
|
||||
self, chat_client_base: "MockBaseChatClient"
|
||||
) -> None:
|
||||
@@ -450,7 +531,9 @@ class TestChatMiddleware:
|
||||
# Execute the chat client directly with run-level middleware and tools
|
||||
messages = [Message(role="user", text="What's the weather in New York?")]
|
||||
response = await client.get_response(
|
||||
messages, options={"tools": [sample_tool_wrapped]}, middleware=[run_level_function_middleware]
|
||||
messages,
|
||||
options={"tools": [sample_tool_wrapped]},
|
||||
client_kwargs={"middleware": [run_level_function_middleware]},
|
||||
)
|
||||
|
||||
# Verify response
|
||||
@@ -463,3 +546,156 @@ class TestChatMiddleware:
|
||||
"run_level_function_middleware_before",
|
||||
"run_level_function_middleware_after",
|
||||
]
|
||||
|
||||
async def test_run_level_chat_and_function_middleware_split_per_function_loop_round(self) -> None:
|
||||
"""Test mixed run-level middleware is split so chat middleware runs per model call."""
|
||||
execution_order: list[str] = []
|
||||
chat_round = 0
|
||||
|
||||
@chat_middleware
|
||||
async def run_level_chat_middleware(
|
||||
context: ChatContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
nonlocal chat_round
|
||||
chat_round += 1
|
||||
execution_order.append(f"chat_middleware_before_{chat_round}")
|
||||
await call_next()
|
||||
execution_order.append(f"chat_middleware_after_{chat_round}")
|
||||
|
||||
@function_middleware
|
||||
async def run_level_function_middleware(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
execution_order.append("function_middleware_before")
|
||||
await call_next()
|
||||
execution_order.append("function_middleware_after")
|
||||
|
||||
def sample_tool(location: str) -> str:
|
||||
"""Get weather for a location."""
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
sample_tool_wrapped = FunctionTool(
|
||||
func=sample_tool,
|
||||
name="sample_tool",
|
||||
description="Get weather for a location",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
client = MockBaseChatClient()
|
||||
client.run_responses = [
|
||||
ChatResponse(
|
||||
messages=[
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="call_3",
|
||||
name="sample_tool",
|
||||
arguments={"location": "Seattle"},
|
||||
)
|
||||
],
|
||||
)
|
||||
]
|
||||
),
|
||||
ChatResponse(messages=[Message(role="assistant", text="Based on the weather data, it's sunny!")]),
|
||||
]
|
||||
|
||||
response = await client.get_response(
|
||||
[Message(role="user", text="What's the weather in Seattle?")],
|
||||
options={"tools": [sample_tool_wrapped]},
|
||||
client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]},
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert client.call_count == 2
|
||||
assert response.messages[-1].text == "Based on the weather data, it's sunny!"
|
||||
assert execution_order == [
|
||||
"chat_middleware_before_1",
|
||||
"chat_middleware_after_1",
|
||||
"function_middleware_before",
|
||||
"function_middleware_after",
|
||||
"chat_middleware_before_2",
|
||||
"chat_middleware_after_2",
|
||||
]
|
||||
|
||||
async def test_run_level_chat_and_function_middleware_split_per_function_loop_round_streaming(self) -> None:
|
||||
"""Test mixed run-level middleware is split so chat middleware runs per model call in streaming mode."""
|
||||
execution_order: list[str] = []
|
||||
chat_round = 0
|
||||
|
||||
@chat_middleware
|
||||
async def run_level_chat_middleware(
|
||||
context: ChatContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
nonlocal chat_round
|
||||
chat_round += 1
|
||||
execution_order.append(f"chat_middleware_before_{chat_round}")
|
||||
await call_next()
|
||||
execution_order.append(f"chat_middleware_after_{chat_round}")
|
||||
|
||||
@function_middleware
|
||||
async def run_level_function_middleware(
|
||||
context: FunctionInvocationContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
execution_order.append("function_middleware_before")
|
||||
await call_next()
|
||||
execution_order.append("function_middleware_after")
|
||||
|
||||
def sample_tool(location: str) -> str:
|
||||
"""Get weather for a location."""
|
||||
return f"Weather in {location}: sunny"
|
||||
|
||||
sample_tool_wrapped = FunctionTool(
|
||||
func=sample_tool,
|
||||
name="sample_tool",
|
||||
description="Get weather for a location",
|
||||
approval_mode="never_require",
|
||||
)
|
||||
|
||||
client = MockBaseChatClient()
|
||||
client.streaming_responses = [
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[
|
||||
Content.from_function_call(
|
||||
call_id="call_3",
|
||||
name="sample_tool",
|
||||
arguments='{"location": "Seattle"}',
|
||||
)
|
||||
],
|
||||
role="assistant",
|
||||
finish_reason="tool_calls",
|
||||
),
|
||||
],
|
||||
[
|
||||
ChatResponseUpdate(
|
||||
contents=[Content.from_text("Based on the weather data, it's sunny!")],
|
||||
role="assistant",
|
||||
finish_reason="stop",
|
||||
),
|
||||
],
|
||||
]
|
||||
|
||||
updates: list[ChatResponseUpdate] = []
|
||||
async for update in client.get_response(
|
||||
[Message(role="user", text="What's the weather in Seattle?")],
|
||||
options={"tools": [sample_tool_wrapped]},
|
||||
client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]},
|
||||
stream=True,
|
||||
):
|
||||
updates.append(update)
|
||||
|
||||
assert client.call_count == 2
|
||||
assert len(updates) > 0
|
||||
assert execution_order == [
|
||||
"chat_middleware_before_1",
|
||||
"chat_middleware_after_1",
|
||||
"function_middleware_before",
|
||||
"function_middleware_after",
|
||||
"chat_middleware_before_2",
|
||||
"chat_middleware_after_2",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ from agent_framework import (
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
Message,
|
||||
RawAgent,
|
||||
ResponseStream,
|
||||
SupportsAgentRun,
|
||||
UsageDetails,
|
||||
@@ -1033,6 +1034,272 @@ def test_enable_instrumentation_with_sensitive_data(monkeypatch):
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test enable_instrumentation re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_SENSITIVE_DATA from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_vs_code_port(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads VS_CODE_EXTENSION_PORT from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port is None
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("VS_CODE_EXTENSION_PORT", "4317")
|
||||
|
||||
# Mock _configure to avoid needing optional OTLP gRPC exporter dependency
|
||||
with mock_patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit parameters to configure_otel_providers override env vars."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
observability.configure_otel_providers(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_explicit_param_overrides_env(monkeypatch):
|
||||
"""Test that explicit enable_sensitive_data parameter to enable_instrumentation overrides env var."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
observability.enable_instrumentation(enable_sensitive_data=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_does_not_touch_console_exporters(monkeypatch):
|
||||
"""Test enable_instrumentation does not modify enable_console_exporters (it is an exporter concern)."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability.enable_instrumentation()
|
||||
# enable_console_exporters is not managed by enable_instrumentation;
|
||||
# it is only read by configure_otel_providers.
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
def test_enable_instrumentation_does_not_clobber_console_exporters(monkeypatch):
|
||||
"""Test enable_instrumentation does not reset enable_console_exporters set by prior configure call."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_instrumentation should not clobber the value
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_with_sensitive_data_does_not_touch_console_exporters(monkeypatch):
|
||||
"""Test enable_console_exporters is untouched even when enable_sensitive_data is explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
monkeypatch.delenv("ENABLE_SENSITIVE_DATA", raising=False)
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Set console exporters via configure_otel_providers
|
||||
observability.configure_otel_providers(enable_console_exporters=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Calling enable_instrumentation with explicit sensitive_data should not clobber console exporters
|
||||
observability.enable_instrumentation(enable_sensitive_data=True)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_enable_instrumentation_preserves_console_exporters_after_env_removed(monkeypatch):
|
||||
"""Test enable_instrumentation preserves enable_console_exporters when env var is removed after reload."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
# Remove the env var after reload
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
|
||||
# enable_instrumentation should not reset the value
|
||||
observability.enable_instrumentation()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
|
||||
"""Test configure_otel_providers re-reads ENABLE_CONSOLE_EXPORTERS from os.environ when not explicitly passed."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
monkeypatch.delenv("ENABLE_CONSOLE_EXPORTERS", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
# Simulate load_dotenv() setting env var after import
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
|
||||
observability.configure_otel_providers()
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
|
||||
|
||||
|
||||
def test_configure_otel_providers_explicit_console_exporters_overrides_env(monkeypatch):
|
||||
"""Test that explicit enable_console_exporters parameter overrides the environment variable."""
|
||||
import importlib
|
||||
|
||||
monkeypatch.setenv("ENABLE_INSTRUMENTATION", "false")
|
||||
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
|
||||
monkeypatch.delenv("VS_CODE_EXTENSION_PORT", raising=False)
|
||||
for key in [
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
||||
]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
observability = importlib.import_module("agent_framework.observability")
|
||||
importlib.reload(observability)
|
||||
|
||||
# Explicit False should override the env var True
|
||||
observability.configure_otel_providers(enable_console_exporters=False)
|
||||
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
|
||||
|
||||
|
||||
# region Test _to_otel_part content types
|
||||
|
||||
|
||||
@@ -2170,7 +2437,7 @@ def test_capture_response(span_exporter: InMemorySpanExporter):
|
||||
async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: InMemorySpanExporter):
|
||||
"""Test that with correct layer ordering, spans appear in the expected sequence.
|
||||
|
||||
When using the correct layer ordering (ChatMiddlewareLayer, FunctionInvocationLayer,
|
||||
When using the correct layer ordering (FunctionInvocationLayer, ChatMiddlewareLayer,
|
||||
ChatTelemetryLayer, BaseChatClient), the spans should appear in this order:
|
||||
1. First 'chat' span (initial LLM call that returns function call)
|
||||
2. 'execute_tool' span (function invocation)
|
||||
@@ -2187,11 +2454,11 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter:
|
||||
def get_weather(location: str) -> str:
|
||||
return f"The weather in {location} is sunny."
|
||||
|
||||
# Correct layer ordering: FunctionInvocationLayer BEFORE ChatTelemetryLayer
|
||||
# This ensures each inner LLM call gets its own telemetry span
|
||||
# Correct layer ordering: FunctionInvocationLayer BEFORE ChatMiddlewareLayer BEFORE ChatTelemetryLayer
|
||||
# This ensures each inner LLM call traverses chat middleware and still gets its own telemetry span
|
||||
class MockChatClientWithLayers(
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationLayer,
|
||||
ChatMiddlewareLayer,
|
||||
ChatTelemetryLayer,
|
||||
BaseChatClient,
|
||||
):
|
||||
@@ -2781,3 +3048,143 @@ def test_get_meter_typeerror_fallback():
|
||||
meter = get_meter(name="test", attributes={"key": "val"})
|
||||
assert meter is not None
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# region Agent token usage aggregation
|
||||
|
||||
|
||||
@tool(name="get_weather", description="Get weather for a city", approval_mode="never_require")
|
||||
def _get_weather(city: str) -> str:
|
||||
"""Get weather for a city."""
|
||||
return "Sunny, 72°F"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
|
||||
async def test_agent_invoke_span_aggregates_usage_across_tool_calls(span_exporter: InMemorySpanExporter):
|
||||
"""The invoke_agent span should sum token usage from all chat completions in the function invocation loop."""
|
||||
from tests.core.conftest import MockBaseChatClient
|
||||
|
||||
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
|
||||
pass
|
||||
|
||||
client = MockBaseChatClient()
|
||||
client.run_responses = [
|
||||
ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}')
|
||||
],
|
||||
),
|
||||
usage_details=UsageDetails(input_token_count=2239, output_token_count=192),
|
||||
),
|
||||
ChatResponse(
|
||||
messages=Message(role="assistant", text="The weather in Seattle is sunny."),
|
||||
usage_details=UsageDetails(input_token_count=2569, output_token_count=99),
|
||||
),
|
||||
]
|
||||
|
||||
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
|
||||
|
||||
span_exporter.clear()
|
||||
await agent.run(
|
||||
messages="What is the weather in Seattle?",
|
||||
options={"tools": [_get_weather], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
|
||||
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(invoke_spans) == 1
|
||||
agent_span = invoke_spans[0]
|
||||
|
||||
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
|
||||
assert len(chat_spans) == 2
|
||||
|
||||
# Individual chat spans retain their own usage
|
||||
assert chat_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 2239
|
||||
assert chat_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 192
|
||||
assert chat_spans[1].attributes.get(OtelAttr.INPUT_TOKENS) == 2569
|
||||
assert chat_spans[1].attributes.get(OtelAttr.OUTPUT_TOKENS) == 99
|
||||
|
||||
# The invoke_agent span must report the aggregate across all LLM round-trips
|
||||
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 2239 + 2569
|
||||
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 192 + 99
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
|
||||
async def test_agent_invoke_span_usage_single_call(span_exporter: InMemorySpanExporter):
|
||||
"""When only one chat completion occurs, the invoke_agent span usage equals that single call."""
|
||||
from tests.core.conftest import MockBaseChatClient
|
||||
|
||||
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
|
||||
pass
|
||||
|
||||
client = MockBaseChatClient()
|
||||
client.run_responses = [
|
||||
ChatResponse(
|
||||
messages=Message(role="assistant", text="Hello!"),
|
||||
usage_details=UsageDetails(input_token_count=100, output_token_count=50),
|
||||
),
|
||||
]
|
||||
|
||||
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
|
||||
|
||||
span_exporter.clear()
|
||||
await agent.run(messages="Hi")
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(invoke_spans) == 1
|
||||
|
||||
assert invoke_spans[0].attributes.get(OtelAttr.INPUT_TOKENS) == 100
|
||||
assert invoke_spans[0].attributes.get(OtelAttr.OUTPUT_TOKENS) == 50
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_sensitive_data", [False], indirect=True)
|
||||
async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(span_exporter: InMemorySpanExporter):
|
||||
"""When the function invocation loop exhausts max_iterations, the final response aggregates usage
|
||||
from all rounds."""
|
||||
from tests.core.conftest import MockBaseChatClient
|
||||
|
||||
class _InstrumentedAgent(AgentTelemetryLayer, RawAgent):
|
||||
pass
|
||||
|
||||
client = MockBaseChatClient(
|
||||
function_invocation_configuration={"max_iterations": 1},
|
||||
)
|
||||
client.run_responses = [
|
||||
# Iteration 0: model returns a tool call
|
||||
ChatResponse(
|
||||
messages=Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="call_1", name="get_weather", arguments='{"city": "Seattle"}')
|
||||
],
|
||||
),
|
||||
usage_details=UsageDetails(input_token_count=500, output_token_count=100),
|
||||
),
|
||||
# Exhaustion path: consumed by tool_choice="none" final call (mock ignores usage)
|
||||
ChatResponse(
|
||||
messages=Message(role="assistant", text="placeholder"),
|
||||
usage_details=UsageDetails(input_token_count=300, output_token_count=60),
|
||||
),
|
||||
]
|
||||
|
||||
agent = _InstrumentedAgent(client=client, name="test_agent", id="test_agent_id")
|
||||
|
||||
span_exporter.clear()
|
||||
await agent.run(
|
||||
messages="What is the weather in Seattle?",
|
||||
options={"tools": [_get_weather], "tool_choice": "auto"},
|
||||
)
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
|
||||
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
|
||||
assert len(invoke_spans) == 1
|
||||
agent_span = invoke_spans[0]
|
||||
|
||||
# The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call
|
||||
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500
|
||||
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import AsyncIterable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -1710,6 +1711,47 @@ def test_content_roundtrip_preserves_compaction_annotation_dict() -> None:
|
||||
assert annotation[GROUP_TOKEN_COUNT_KEY] is None
|
||||
|
||||
|
||||
def test_content_from_dict_via_json() -> None:
|
||||
"""Test Content.from_dict with data parsed from a JSON string."""
|
||||
data = json.loads(json.dumps({"type": "text", "text": "Hello world"}))
|
||||
content = Content.from_dict(data)
|
||||
assert content.type == "text"
|
||||
assert content.text == "Hello world"
|
||||
|
||||
|
||||
def test_content_from_dict_roundtrip_via_json() -> None:
|
||||
"""Test Content.from_dict roundtrip via to_dict and json.dumps."""
|
||||
original = Content.from_function_call(call_id="call1", name="my_func", arguments={"key": "value"})
|
||||
data = json.loads(json.dumps(original.to_dict()))
|
||||
restored = Content.from_dict(data)
|
||||
assert restored.type == "function_call"
|
||||
assert restored.call_id == "call1"
|
||||
assert restored.name == "my_func"
|
||||
assert restored.arguments == {"key": "value"}
|
||||
|
||||
|
||||
def test_content_to_dict_exclude_none() -> None:
|
||||
"""Test Content.to_dict excludes None fields by default."""
|
||||
content = Content.from_text("Hello")
|
||||
d = content.to_dict()
|
||||
parsed = json.loads(json.dumps(d))
|
||||
assert "uri" not in parsed
|
||||
|
||||
d_with_none = content.to_dict(exclude_none=False)
|
||||
parsed_with_none = json.loads(json.dumps(d_with_none))
|
||||
assert "uri" in parsed_with_none
|
||||
assert parsed_with_none["uri"] is None
|
||||
|
||||
|
||||
def test_content_to_dict_exclude_fields() -> None:
|
||||
"""Test Content.to_dict with explicit field exclusion."""
|
||||
content = Content.from_text("Hello")
|
||||
d = content.to_dict(exclude={"text"})
|
||||
parsed = json.loads(json.dumps(d))
|
||||
assert "text" not in parsed
|
||||
assert parsed["type"] == "text"
|
||||
|
||||
|
||||
def test_chat_response_roundtrip_preserves_compaction_annotation_dict() -> None:
|
||||
response = ChatResponse(
|
||||
messages=[
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user