mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0a15914bf | ||
|
|
45527eed29 |
@@ -1,207 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,293 +0,0 @@
|
||||
# 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
|
||||
@@ -1,49 +0,0 @@
|
||||
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' }}
|
||||
+1
-51
@@ -7,55 +7,6 @@ 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
|
||||
@@ -817,8 +768,7 @@ 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.0rc5...HEAD
|
||||
[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...HEAD
|
||||
[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
|
||||
|
||||
@@ -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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"a2a-sdk>=0.3.5,<0.3.24",
|
||||
]
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from ag_ui.core import (
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
ToolCallStartEvent,
|
||||
)
|
||||
from agent_framework import (
|
||||
@@ -370,24 +369,6 @@ 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).
|
||||
|
||||
@@ -410,7 +391,7 @@ async def _resolve_approval_responses(
|
||||
run_kwargs: dict[str, Any],
|
||||
pending_approvals: dict[str, str] | None = None,
|
||||
thread_id: str = "",
|
||||
) -> list[Content]:
|
||||
) -> None:
|
||||
"""Execute approved function calls and replace approval content with results.
|
||||
|
||||
This modifies the messages list in place, replacing function_approval_response
|
||||
@@ -426,16 +407,10 @@ 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]
|
||||
@@ -518,23 +493,31 @@ async def _resolve_approval_responses(
|
||||
logger.exception("Failed to execute approved tool calls; injecting error results: %s", e)
|
||||
approved_function_results = []
|
||||
|
||||
# Build results for approved responses (used for TOOL_CALL_RESULT event emission)
|
||||
approved_results: list[Content] = []
|
||||
# Build normalized results for approved responses
|
||||
normalized_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"
|
||||
):
|
||||
approved_results.append(approved_function_results[idx])
|
||||
normalized_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 ""
|
||||
approved_results.append(
|
||||
normalized_results.append(
|
||||
Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.")
|
||||
)
|
||||
|
||||
_replace_approval_contents_with_results(messages, fcc_todo, approved_results) # type: ignore
|
||||
# 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
|
||||
|
||||
# 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
|
||||
@@ -542,8 +525,6 @@ 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.
|
||||
@@ -806,9 +787,7 @@ 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
|
||||
resolved_approval_results = await _resolve_approval_responses(
|
||||
messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id
|
||||
)
|
||||
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.
|
||||
@@ -872,9 +851,6 @@ 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):
|
||||
@@ -929,8 +905,7 @@ async def run_agent_stream(
|
||||
if state_schema and flow.current_state:
|
||||
yield StateSnapshotEvent(snapshot=flow.current_state)
|
||||
|
||||
for event in _make_approval_tool_result_events(resolved_approval_results):
|
||||
yield event
|
||||
# Process structured output if response_format is set
|
||||
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(
|
||||
FunctionInvocationLayer[AGUIChatOptionsT],
|
||||
ChatMiddlewareLayer[AGUIChatOptionsT],
|
||||
FunctionInvocationLayer[AGUIChatOptionsT],
|
||||
ChatTelemetryLayer[AGUIChatOptionsT],
|
||||
BaseChatClient[AGUIChatOptionsT],
|
||||
Generic[AGUIChatOptionsT],
|
||||
|
||||
@@ -12,12 +12,6 @@ from typing import Any, cast
|
||||
from ag_ui.core import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
ReasoningEncryptedValueEvent,
|
||||
ReasoningEndEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
RunFinishedEvent,
|
||||
StateSnapshotEvent,
|
||||
TextMessageContentEvent,
|
||||
@@ -230,28 +224,27 @@ def _emit_tool_call(
|
||||
return events
|
||||
|
||||
|
||||
def _emit_tool_result_common(
|
||||
call_id: str,
|
||||
raw_result: Any,
|
||||
def _emit_tool_result(
|
||||
content: Content,
|
||||
flow: FlowState,
|
||||
predictive_handler: PredictiveStateHandler | None = None,
|
||||
) -> list[BaseEvent]:
|
||||
"""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.
|
||||
"""
|
||||
"""Emit ToolCallResult events for function_result content."""
|
||||
events: list[BaseEvent] = []
|
||||
|
||||
events.append(ToolCallEndEvent(tool_call_id=call_id))
|
||||
flow.tool_calls_ended.add(call_id)
|
||||
if not content.call_id:
|
||||
return events
|
||||
|
||||
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=call_id,
|
||||
tool_call_id=content.call_id,
|
||||
content=result_content,
|
||||
role="tool",
|
||||
)
|
||||
@@ -261,7 +254,7 @@ def _emit_tool_result_common(
|
||||
{
|
||||
"id": message_id,
|
||||
"role": "tool",
|
||||
"toolCallId": call_id,
|
||||
"toolCallId": content.call_id,
|
||||
"content": result_content,
|
||||
}
|
||||
)
|
||||
@@ -275,7 +268,7 @@ def _emit_tool_result_common(
|
||||
flow.tool_call_name = None
|
||||
|
||||
if flow.message_id:
|
||||
logger.debug("Closing text message: message_id=%s", flow.message_id)
|
||||
logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id)
|
||||
events.append(TextMessageEndEvent(message_id=flow.message_id))
|
||||
flow.message_id = None
|
||||
flow.accumulated_text = ""
|
||||
@@ -283,18 +276,6 @@ def _emit_tool_result_common(
|
||||
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,
|
||||
@@ -400,107 +381,6 @@ 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,
|
||||
@@ -522,11 +402,5 @@ 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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"ag-ui-protocol==0.1.13",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
|
||||
@@ -45,8 +45,8 @@ def pytest_configure() -> None:
|
||||
|
||||
|
||||
class StreamingChatClientStub(
|
||||
FunctionInvocationLayer[OptionsCoT],
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
FunctionInvocationLayer[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__(middleware=[])
|
||||
super().__init__(function_middleware=[])
|
||||
self._stream_fn = stream_fn
|
||||
self._response_fn = response_fn
|
||||
self.last_session: AgentSession | None = None
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
# 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,134 +213,3 @@ 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,12 +5,6 @@
|
||||
import pytest
|
||||
from ag_ui.core import (
|
||||
CustomEvent,
|
||||
ReasoningEncryptedValueEvent,
|
||||
ReasoningEndEvent,
|
||||
ReasoningMessageContentEvent,
|
||||
ReasoningMessageEndEvent,
|
||||
ReasoningMessageStartEvent,
|
||||
ReasoningStartEvent,
|
||||
TextMessageEndEvent,
|
||||
TextMessageStartEvent,
|
||||
ToolCallArgsEvent,
|
||||
@@ -31,10 +25,7 @@ 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,
|
||||
@@ -1000,349 +991,3 @@ 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, RawAnthropicClient
|
||||
from ._chat_client import AnthropicChatOptions, AnthropicClient
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,6 +12,5 @@ except importlib.metadata.PackageNotFoundError:
|
||||
__all__ = [
|
||||
"AnthropicChatOptions",
|
||||
"AnthropicClient",
|
||||
"RawAnthropicClient",
|
||||
"__version__",
|
||||
]
|
||||
|
||||
@@ -68,7 +68,6 @@ else:
|
||||
__all__ = [
|
||||
"AnthropicChatOptions",
|
||||
"AnthropicClient",
|
||||
"RawAnthropicClient",
|
||||
"ThinkingConfig",
|
||||
]
|
||||
|
||||
@@ -211,24 +210,14 @@ class AnthropicSettings(TypedDict, total=False):
|
||||
chat_model_id: str | None
|
||||
|
||||
|
||||
class RawAnthropicClient(
|
||||
class AnthropicClient(
|
||||
ChatMiddlewareLayer[AnthropicOptionsT],
|
||||
FunctionInvocationLayer[AnthropicOptionsT],
|
||||
ChatTelemetryLayer[AnthropicOptionsT],
|
||||
BaseChatClient[AnthropicOptionsT],
|
||||
Generic[AnthropicOptionsT],
|
||||
):
|
||||
"""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.
|
||||
"""
|
||||
"""Anthropic Chat client with middleware, telemetry, and function invocation support."""
|
||||
|
||||
OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc]
|
||||
|
||||
@@ -240,10 +229,12 @@ class RawAnthropicClient(
|
||||
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 a raw Anthropic client.
|
||||
"""Initialize an Anthropic Agent client.
|
||||
|
||||
Keyword Args:
|
||||
api_key: The Anthropic API key to use for authentication.
|
||||
@@ -254,13 +245,15 @@ class RawAnthropicClient(
|
||||
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 RawAnthropicClient
|
||||
from agent_framework.anthropic import AnthropicClient
|
||||
from azure.identity.aio import DefaultAzureCredential
|
||||
|
||||
# Using environment variables
|
||||
@@ -268,13 +261,13 @@ class RawAnthropicClient(
|
||||
# ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929
|
||||
|
||||
# Or passing parameters directly
|
||||
client = RawAnthropicClient(
|
||||
client = AnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
api_key="your_anthropic_api_key",
|
||||
)
|
||||
|
||||
# Or loading from a .env file
|
||||
client = RawAnthropicClient(env_file_path="path/to/.env")
|
||||
client = AnthropicClient(env_file_path="path/to/.env")
|
||||
|
||||
# Or passing in an existing client
|
||||
from anthropic import AsyncAnthropic
|
||||
@@ -282,7 +275,7 @@ class RawAnthropicClient(
|
||||
anthropic_client = AsyncAnthropic(
|
||||
api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com"
|
||||
)
|
||||
client = RawAnthropicClient(
|
||||
client = AnthropicClient(
|
||||
model_id="claude-sonnet-4-5-20250929",
|
||||
anthropic_client=anthropic_client,
|
||||
)
|
||||
@@ -296,7 +289,7 @@ class RawAnthropicClient(
|
||||
my_custom_option: str
|
||||
|
||||
|
||||
client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929")
|
||||
client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929")
|
||||
response = await client.get_response("Hello", options={"my_custom_option": "value"})
|
||||
|
||||
"""
|
||||
@@ -327,6 +320,8 @@ class RawAnthropicClient(
|
||||
# Initialize parent
|
||||
super().__init__(
|
||||
additional_properties=additional_properties,
|
||||
middleware=middleware,
|
||||
function_invocation_configuration=function_invocation_configuration,
|
||||
)
|
||||
|
||||
# Initialize instance variables
|
||||
@@ -1381,95 +1376,3 @@ class RawAnthropicClient(
|
||||
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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"anthropic>=0.80.0,<0.80.1",
|
||||
]
|
||||
|
||||
|
||||
@@ -6,18 +6,15 @@ 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,
|
||||
@@ -26,7 +23,7 @@ from anthropic.types.beta import (
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_anthropic import AnthropicClient, RawAnthropicClient
|
||||
from agent_framework_anthropic import AnthropicClient
|
||||
from agent_framework_anthropic._chat_client import AnthropicSettings
|
||||
|
||||
# Test constants
|
||||
@@ -67,8 +64,6 @@ 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
|
||||
@@ -122,19 +117,6 @@ 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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-search-documents>=11.7.0b2,<11.7.0b3",
|
||||
]
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ from ._embedding_client import (
|
||||
AzureAIInferenceEmbeddingSettings,
|
||||
RawAzureAIInferenceEmbeddingClient,
|
||||
)
|
||||
from ._foundry_evals import (
|
||||
FoundryEvals,
|
||||
evaluate_foundry_target,
|
||||
evaluate_traces,
|
||||
)
|
||||
from ._foundry_memory_provider import FoundryMemoryProvider
|
||||
from ._project_provider import AzureAIProjectAgentProvider
|
||||
from ._shared import AzureAISettings
|
||||
@@ -31,8 +36,11 @@ __all__ = [
|
||||
"AzureAIProjectAgentOptions",
|
||||
"AzureAIProjectAgentProvider",
|
||||
"AzureAISettings",
|
||||
"FoundryEvals",
|
||||
"FoundryMemoryProvider",
|
||||
"RawAzureAIClient",
|
||||
"RawAzureAIInferenceEmbeddingClient",
|
||||
"__version__",
|
||||
"evaluate_foundry_target",
|
||||
"evaluate_traces",
|
||||
]
|
||||
|
||||
@@ -206,8 +206,8 @@ AzureAIAgentOptionsT = TypeVar(
|
||||
|
||||
|
||||
class AzureAIAgentClient(
|
||||
FunctionInvocationLayer[AzureAIAgentOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIAgentOptionsT],
|
||||
FunctionInvocationLayer[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. **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
|
||||
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
|
||||
|
||||
Use ``AzureAIClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
@@ -1214,8 +1214,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[
|
||||
|
||||
|
||||
class AzureAIClient(
|
||||
FunctionInvocationLayer[AzureAIClientOptionsT],
|
||||
ChatMiddlewareLayer[AzureAIClientOptionsT],
|
||||
FunctionInvocationLayer[AzureAIClientOptionsT],
|
||||
ChatTelemetryLayer[AzureAIClientOptionsT],
|
||||
RawAzureAIClient[AzureAIClientOptionsT],
|
||||
Generic[AzureAIClientOptionsT],
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Microsoft Foundry Evals integration for Microsoft Agent Framework.
|
||||
|
||||
Provides ``FoundryEvals``, an ``Evaluator`` implementation backed by Azure AI
|
||||
Foundry's built-in evaluators. See docs/decisions/0018-foundry-evals-integration.md
|
||||
for the design rationale.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from agent_framework import evaluate_agent
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment="gpt-4o")
|
||||
results = await evaluate_agent(
|
||||
agent=my_agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=evals,
|
||||
)
|
||||
assert results.all_passed
|
||||
print(results.report_url)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, Sequence, cast
|
||||
|
||||
from agent_framework._evaluation import (
|
||||
ConversationSplit,
|
||||
ConversationSplitter,
|
||||
EvalItem,
|
||||
EvalItemResult,
|
||||
EvalResults,
|
||||
EvalScoreResult,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Agent evaluators that accept query/response as conversation arrays.
|
||||
# Maintained manually — check https://learn.microsoft.com/en-us/azure/ai-studio/how-to/develop/evaluate-sdk
|
||||
# for the latest evaluator list. These are the evaluators that need conversation-format input.
|
||||
_AGENT_EVALUATORS: set[str] = {
|
||||
"builtin.intent_resolution",
|
||||
"builtin.task_adherence",
|
||||
"builtin.task_completion",
|
||||
"builtin.task_navigation_efficiency",
|
||||
"builtin.tool_call_accuracy",
|
||||
"builtin.tool_selection",
|
||||
"builtin.tool_input_accuracy",
|
||||
"builtin.tool_output_utilization",
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
# Evaluators that additionally require tool_definitions.
|
||||
_TOOL_EVALUATORS: set[str] = {
|
||||
"builtin.tool_call_accuracy",
|
||||
"builtin.tool_selection",
|
||||
"builtin.tool_input_accuracy",
|
||||
"builtin.tool_output_utilization",
|
||||
"builtin.tool_call_success",
|
||||
}
|
||||
|
||||
_BUILTIN_EVALUATORS: dict[str, str] = {
|
||||
# Agent behavior
|
||||
"intent_resolution": "builtin.intent_resolution",
|
||||
"task_adherence": "builtin.task_adherence",
|
||||
"task_completion": "builtin.task_completion",
|
||||
"task_navigation_efficiency": "builtin.task_navigation_efficiency",
|
||||
# Tool usage
|
||||
"tool_call_accuracy": "builtin.tool_call_accuracy",
|
||||
"tool_selection": "builtin.tool_selection",
|
||||
"tool_input_accuracy": "builtin.tool_input_accuracy",
|
||||
"tool_output_utilization": "builtin.tool_output_utilization",
|
||||
"tool_call_success": "builtin.tool_call_success",
|
||||
# Quality
|
||||
"coherence": "builtin.coherence",
|
||||
"fluency": "builtin.fluency",
|
||||
"relevance": "builtin.relevance",
|
||||
"groundedness": "builtin.groundedness",
|
||||
"response_completeness": "builtin.response_completeness",
|
||||
"similarity": "builtin.similarity",
|
||||
# Safety
|
||||
"violence": "builtin.violence",
|
||||
"sexual": "builtin.sexual",
|
||||
"self_harm": "builtin.self_harm",
|
||||
"hate_unfairness": "builtin.hate_unfairness",
|
||||
}
|
||||
|
||||
# Default evaluator sets used when evaluators=None
|
||||
_DEFAULT_EVALUATORS: list[str] = [
|
||||
"relevance",
|
||||
"coherence",
|
||||
"task_adherence",
|
||||
]
|
||||
|
||||
_DEFAULT_TOOL_EVALUATORS: list[str] = [
|
||||
"tool_call_accuracy",
|
||||
]
|
||||
|
||||
|
||||
def _resolve_evaluator(name: str) -> str:
|
||||
"""Resolve a short evaluator name to its fully-qualified ``builtin.*`` form.
|
||||
|
||||
Args:
|
||||
name: Short name (e.g. ``"relevance"``) or fully-qualified name
|
||||
(e.g. ``"builtin.relevance"``).
|
||||
|
||||
Returns:
|
||||
The fully-qualified evaluator name.
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is not recognized.
|
||||
"""
|
||||
if name.startswith("builtin."):
|
||||
return name
|
||||
resolved = _BUILTIN_EVALUATORS.get(name)
|
||||
if resolved is None:
|
||||
raise ValueError(f"Unknown evaluator '{name}'. Available: {sorted(_BUILTIN_EVALUATORS)}")
|
||||
return resolved
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_testing_criteria(
|
||||
evaluators: Sequence[str],
|
||||
model_deployment: str,
|
||||
*,
|
||||
include_data_mapping: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build ``testing_criteria`` for ``evals.create()``.
|
||||
|
||||
Args:
|
||||
evaluators: Evaluator names.
|
||||
model_deployment: Model deployment for the LLM judge.
|
||||
include_data_mapping: Whether to include field-level data mapping
|
||||
(required for the JSONL data source, not needed for response-based).
|
||||
"""
|
||||
criteria: list[dict[str, Any]] = []
|
||||
for name in evaluators:
|
||||
qualified = _resolve_evaluator(name)
|
||||
short = name if not name.startswith("builtin.") else name.split(".")[-1]
|
||||
|
||||
entry: dict[str, Any] = {
|
||||
"type": "azure_ai_evaluator",
|
||||
"name": short,
|
||||
"evaluator_name": qualified,
|
||||
"initialization_parameters": {"deployment_name": model_deployment},
|
||||
}
|
||||
|
||||
if include_data_mapping:
|
||||
if qualified in _AGENT_EVALUATORS:
|
||||
# Agent evaluators: query/response as conversation arrays
|
||||
mapping: dict[str, str] = {
|
||||
"query": "{{item.query_messages}}",
|
||||
"response": "{{item.response_messages}}",
|
||||
}
|
||||
else:
|
||||
# Quality evaluators: query/response as strings
|
||||
mapping = {
|
||||
"query": "{{item.query}}",
|
||||
"response": "{{item.response}}",
|
||||
}
|
||||
if qualified == "builtin.groundedness":
|
||||
mapping["context"] = "{{item.context}}"
|
||||
if qualified in _TOOL_EVALUATORS:
|
||||
mapping["tool_definitions"] = "{{item.tool_definitions}}"
|
||||
entry["data_mapping"] = mapping
|
||||
|
||||
criteria.append(entry)
|
||||
return criteria
|
||||
|
||||
|
||||
def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) -> dict[str, Any]:
|
||||
"""Build the ``item_schema`` for custom JSONL eval definitions."""
|
||||
properties: dict[str, Any] = {
|
||||
"query": {"type": "string"},
|
||||
"response": {"type": "string"},
|
||||
"query_messages": {"type": "array"},
|
||||
"response_messages": {"type": "array"},
|
||||
}
|
||||
if has_context:
|
||||
properties["context"] = {"type": "string"}
|
||||
if has_tools:
|
||||
properties["tool_definitions"] = {"type": "array"}
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": ["query", "response"],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_default_evaluators(
|
||||
evaluators: Sequence[str] | None,
|
||||
items: Sequence[EvalItem | dict[str, Any]] | None = None,
|
||||
) -> list[str]:
|
||||
"""Resolve evaluators, applying defaults when ``None``.
|
||||
|
||||
Defaults to relevance + coherence + task_adherence. Automatically adds
|
||||
tool_call_accuracy when items contain tools.
|
||||
"""
|
||||
if evaluators is not None:
|
||||
return list(evaluators)
|
||||
|
||||
result = list(_DEFAULT_EVALUATORS)
|
||||
if items is not None:
|
||||
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
|
||||
if has_tools:
|
||||
result.extend(_DEFAULT_TOOL_EVALUATORS)
|
||||
return result
|
||||
|
||||
|
||||
def _filter_tool_evaluators(
|
||||
evaluators: list[str],
|
||||
items: Sequence[EvalItem | dict[str, Any]],
|
||||
) -> list[str]:
|
||||
"""Remove tool evaluators if no items have tool definitions."""
|
||||
has_tools = any((item.tools if isinstance(item, EvalItem) else item.get("tool_definitions")) for item in items)
|
||||
if has_tools:
|
||||
return evaluators
|
||||
filtered = [e for e in evaluators if _resolve_evaluator(e) not in _TOOL_EVALUATORS]
|
||||
return filtered if filtered else list(_DEFAULT_EVALUATORS)
|
||||
|
||||
|
||||
async def _ensure_async_result(func: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Invoke a sync or async client method transparently.
|
||||
|
||||
If ``func`` returns a coroutine (async client), awaits it directly.
|
||||
Otherwise returns the already-resolved result.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
result = func(*args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
return await result
|
||||
return result
|
||||
|
||||
|
||||
async def _poll_eval_run(
|
||||
client: AsyncOpenAI,
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
provider: str = "Microsoft Foundry",
|
||||
*,
|
||||
fetch_output_items: bool = True,
|
||||
) -> EvalResults:
|
||||
"""Poll an eval run until completion or timeout."""
|
||||
loop = asyncio.get_event_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while True:
|
||||
run = await _ensure_async_result(client.evals.runs.retrieve, run_id=run_id, eval_id=eval_id)
|
||||
if run.status in ("completed", "failed", "canceled"):
|
||||
error_msg = None
|
||||
if run.status == "failed":
|
||||
error_msg = (
|
||||
getattr(run, "error", None)
|
||||
or getattr(run, "error_message", None)
|
||||
or getattr(run, "failure_reason", None)
|
||||
)
|
||||
if error_msg and not isinstance(error_msg, str):
|
||||
error_msg = str(error_msg)
|
||||
|
||||
items: list[EvalItemResult] = []
|
||||
if fetch_output_items and run.status == "completed":
|
||||
items = await _fetch_output_items(client, eval_id, run_id)
|
||||
|
||||
return EvalResults(
|
||||
provider=provider,
|
||||
eval_id=eval_id,
|
||||
run_id=run_id,
|
||||
status=run.status,
|
||||
result_counts=_extract_result_counts(run),
|
||||
report_url=getattr(run, "report_url", None),
|
||||
error=error_msg,
|
||||
per_evaluator=_extract_per_evaluator(run),
|
||||
items=items,
|
||||
)
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
return EvalResults(provider=provider, eval_id=eval_id, run_id=run_id, status="timeout")
|
||||
logger.debug("Eval run %s status: %s (%.0fs remaining)", run_id, run.status, remaining)
|
||||
await asyncio.sleep(min(poll_interval, remaining))
|
||||
|
||||
|
||||
def _extract_result_counts(run: Any) -> dict[str, int] | None:
|
||||
"""Safely extract result_counts from an eval run object."""
|
||||
counts = getattr(run, "result_counts", None)
|
||||
if counts is None:
|
||||
return None
|
||||
if isinstance(counts, dict):
|
||||
return cast(dict[str, int], counts)
|
||||
try:
|
||||
attrs = cast(dict[str, Any], vars(counts))
|
||||
return {str(k): v for k, v in attrs.items() if isinstance(v, int)}
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_per_evaluator(run: Any) -> dict[str, dict[str, int]]:
|
||||
"""Safely extract per-evaluator result breakdowns from an eval run."""
|
||||
per_eval: dict[str, dict[str, int]] = {}
|
||||
per_testing_criteria = getattr(run, "per_testing_criteria_results", None)
|
||||
if per_testing_criteria is None:
|
||||
return per_eval
|
||||
try:
|
||||
items = cast(list[Any], per_testing_criteria) if isinstance(per_testing_criteria, list) else [] # type: ignore[redundant-cast]
|
||||
for item in items:
|
||||
name: str = str(getattr(item, "name", None) or getattr(item, "testing_criteria", "unknown"))
|
||||
counts = _extract_result_counts(item)
|
||||
if name and counts:
|
||||
per_eval[name] = counts
|
||||
except (TypeError, AttributeError):
|
||||
pass
|
||||
return per_eval
|
||||
|
||||
|
||||
async def _fetch_output_items(
|
||||
client: AsyncOpenAI,
|
||||
eval_id: str,
|
||||
run_id: str,
|
||||
) -> list[EvalItemResult]:
|
||||
"""Fetch per-item results from the output_items API.
|
||||
|
||||
Converts the provider-specific ``OutputItemListResponse`` objects into
|
||||
provider-agnostic ``EvalItemResult`` instances with per-evaluator scores,
|
||||
error categorization, and token usage.
|
||||
"""
|
||||
items: list[EvalItemResult] = []
|
||||
try:
|
||||
output_items_page = await _ensure_async_result(
|
||||
client.evals.runs.output_items.list,
|
||||
run_id=run_id,
|
||||
eval_id=eval_id,
|
||||
)
|
||||
|
||||
for oi in output_items_page:
|
||||
item_id = getattr(oi, "id", "") or ""
|
||||
status = getattr(oi, "status", "unknown") or "unknown"
|
||||
|
||||
# Extract per-evaluator scores
|
||||
scores: list[EvalScoreResult] = []
|
||||
for r in getattr(oi, "results", []) or []:
|
||||
scores.append(
|
||||
EvalScoreResult(
|
||||
name=getattr(r, "name", "unknown"),
|
||||
score=getattr(r, "score", 0.0),
|
||||
passed=getattr(r, "passed", None),
|
||||
sample=getattr(r, "sample", None),
|
||||
)
|
||||
)
|
||||
|
||||
# Extract error info from sample
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
token_usage: dict[str, int] | None = None
|
||||
input_text: str | None = None
|
||||
output_text: str | None = None
|
||||
response_id: str | None = None
|
||||
|
||||
sample = getattr(oi, "sample", None)
|
||||
if sample is not None:
|
||||
error = getattr(sample, "error", None)
|
||||
if error is not None:
|
||||
code = getattr(error, "code", None)
|
||||
msg = getattr(error, "message", None)
|
||||
if code or msg:
|
||||
error_code = code or None
|
||||
error_message = msg or None
|
||||
|
||||
usage = getattr(sample, "usage", None)
|
||||
if usage is not None:
|
||||
total = getattr(usage, "total_tokens", 0)
|
||||
if total:
|
||||
token_usage = {
|
||||
"prompt_tokens": getattr(usage, "prompt_tokens", 0),
|
||||
"completion_tokens": getattr(usage, "completion_tokens", 0),
|
||||
"total_tokens": total,
|
||||
"cached_tokens": getattr(usage, "cached_tokens", 0),
|
||||
}
|
||||
|
||||
# Extract input/output text
|
||||
sample_input = getattr(sample, "input", None)
|
||||
if sample_input:
|
||||
parts = [getattr(si, "content", "") for si in sample_input if getattr(si, "role", "") == "user"]
|
||||
if parts:
|
||||
input_text = " ".join(parts)
|
||||
|
||||
sample_output = getattr(sample, "output", None)
|
||||
if sample_output:
|
||||
parts = [
|
||||
getattr(so, "content", "") or ""
|
||||
for so in sample_output
|
||||
if getattr(so, "role", "") == "assistant"
|
||||
]
|
||||
if parts:
|
||||
output_text = " ".join(parts)
|
||||
|
||||
# Extract response_id from datasource_item
|
||||
ds_item = getattr(oi, "datasource_item", None)
|
||||
if ds_item and isinstance(ds_item, dict):
|
||||
ds_dict = cast(dict[str, Any], ds_item)
|
||||
resp_id_val = ds_dict.get("resp_id") or ds_dict.get("response_id")
|
||||
response_id = str(resp_id_val) if resp_id_val else None
|
||||
|
||||
items.append(
|
||||
EvalItemResult(
|
||||
item_id=item_id,
|
||||
status=status,
|
||||
scores=scores,
|
||||
error_code=error_code,
|
||||
error_message=error_message,
|
||||
response_id=response_id,
|
||||
input_text=input_text,
|
||||
output_text=output_text,
|
||||
token_usage=token_usage,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Could not fetch output_items for run %s", run_id, exc_info=True)
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _resolve_openai_client(
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
) -> AsyncOpenAI:
|
||||
"""Resolve an OpenAI client from explicit client or project_client."""
|
||||
if openai_client is not None:
|
||||
return openai_client
|
||||
if project_client is not None:
|
||||
return project_client.get_openai_client()
|
||||
raise ValueError("Provide either 'openai_client' or 'project_client'.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FoundryEvals — Evaluator implementation for Microsoft Foundry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FoundryEvals:
|
||||
"""Evaluation provider backed by Microsoft Foundry.
|
||||
|
||||
Implements the ``Evaluator`` protocol so it can be passed to the
|
||||
provider-agnostic ``evaluate_agent()`` and
|
||||
``evaluate_workflow()`` functions from ``agent_framework``.
|
||||
|
||||
Also provides constants for built-in evaluator names for IDE
|
||||
autocomplete and typo prevention::
|
||||
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evaluators = [FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY]
|
||||
|
||||
The simplest usage::
|
||||
|
||||
from agent_framework import evaluate_agent
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
|
||||
evals = FoundryEvals(project_client=client, model_deployment="gpt-4o")
|
||||
results = await evaluate_agent(agent=agent, queries=queries, evaluators=evals)
|
||||
|
||||
**Evaluator selection:**
|
||||
|
||||
By default, runs ``relevance``, ``coherence``, and ``task_adherence``.
|
||||
Automatically adds ``tool_call_accuracy`` when items contain tool
|
||||
definitions. Override with ``evaluators=``.
|
||||
|
||||
**Responses API optimization:**
|
||||
|
||||
When all items have a ``response_id`` and no tool evaluators are needed,
|
||||
uses Foundry's server-side response retrieval path (no data upload).
|
||||
|
||||
Args:
|
||||
project_client: An ``AIProjectClient`` instance (sync or async).
|
||||
Provide this or *openai_client*.
|
||||
openai_client: An ``AsyncOpenAI`` client with evals API.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
evaluators: Evaluator names (e.g. ``["relevance", "tool_call_accuracy"]``).
|
||||
When ``None`` (default), uses smart defaults based on item data.
|
||||
conversation_split: How to split multi-turn conversations into
|
||||
query/response halves. Defaults to ``LAST_TURN``. Pass a
|
||||
``ConversationSplit`` enum value or a custom callable — see
|
||||
``ConversationSplitter``.
|
||||
poll_interval: Seconds between status polls (default 5.0).
|
||||
timeout: Maximum seconds to wait for completion (default 600.0).
|
||||
"""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in evaluator name constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Agent behavior
|
||||
INTENT_RESOLUTION: str = "intent_resolution"
|
||||
TASK_ADHERENCE: str = "task_adherence"
|
||||
TASK_COMPLETION: str = "task_completion"
|
||||
TASK_NAVIGATION_EFFICIENCY: str = "task_navigation_efficiency"
|
||||
|
||||
# Tool usage
|
||||
TOOL_CALL_ACCURACY: str = "tool_call_accuracy"
|
||||
TOOL_SELECTION: str = "tool_selection"
|
||||
TOOL_INPUT_ACCURACY: str = "tool_input_accuracy"
|
||||
TOOL_OUTPUT_UTILIZATION: str = "tool_output_utilization"
|
||||
TOOL_CALL_SUCCESS: str = "tool_call_success"
|
||||
|
||||
# Quality
|
||||
COHERENCE: str = "coherence"
|
||||
FLUENCY: str = "fluency"
|
||||
RELEVANCE: str = "relevance"
|
||||
GROUNDEDNESS: str = "groundedness"
|
||||
RESPONSE_COMPLETENESS: str = "response_completeness"
|
||||
SIMILARITY: str = "similarity"
|
||||
|
||||
# Safety
|
||||
VIOLENCE: str = "violence"
|
||||
SEXUAL: str = "sexual"
|
||||
SELF_HARM: str = "self_harm"
|
||||
HATE_UNFAIRNESS: str = "hate_unfairness"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
project_client: AIProjectClient | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
model_deployment: str,
|
||||
evaluators: Sequence[str] | None = None,
|
||||
conversation_split: ConversationSplitter = ConversationSplit.LAST_TURN,
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
):
|
||||
self.name = "Microsoft Foundry"
|
||||
self._client = _resolve_openai_client(openai_client, project_client)
|
||||
self._model_deployment = model_deployment
|
||||
self._evaluators = list(evaluators) if evaluators is not None else None
|
||||
self._conversation_split = conversation_split
|
||||
self._poll_interval = poll_interval
|
||||
self._timeout = timeout
|
||||
|
||||
async def evaluate(
|
||||
self,
|
||||
items: Sequence[EvalItem],
|
||||
*,
|
||||
eval_name: str = "Agent Framework Eval",
|
||||
) -> EvalResults:
|
||||
"""Evaluate items using Foundry evaluators.
|
||||
|
||||
Implements the ``Evaluator`` protocol. Automatically selects the
|
||||
optimal data path (Responses API vs JSONL dataset) and filters
|
||||
tool evaluators for items without tool definitions.
|
||||
|
||||
Args:
|
||||
items: Eval data items from ``AgentEvalConverter.to_eval_item()``.
|
||||
eval_name: Display name for the evaluation run.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, counts, and portal link.
|
||||
"""
|
||||
# Resolve evaluators with auto-detection
|
||||
resolved = _resolve_default_evaluators(self._evaluators, items=items)
|
||||
# Filter tool evaluators if items don't have tools
|
||||
resolved = _filter_tool_evaluators(resolved, items)
|
||||
|
||||
# Standard JSONL dataset path
|
||||
return await self._evaluate_via_dataset(items, resolved, eval_name)
|
||||
|
||||
# -- Internal evaluation paths --
|
||||
|
||||
async def _evaluate_via_responses(
|
||||
self,
|
||||
response_ids: Sequence[str],
|
||||
evaluators: list[str],
|
||||
eval_name: str,
|
||||
) -> EvalResults:
|
||||
"""Evaluate using Foundry's Responses API retrieval path."""
|
||||
eval_obj = await _ensure_async_result(
|
||||
self._client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={"type": "azure_ai_source", "scenario": "responses"},
|
||||
testing_criteria=_build_testing_criteria(evaluators, self._model_deployment),
|
||||
)
|
||||
|
||||
data_source = {
|
||||
"type": "azure_ai_responses",
|
||||
"item_generation_params": {
|
||||
"type": "response_retrieval",
|
||||
"data_mapping": {"response_id": "{{item.resp_id}}"},
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": {"resp_id": rid}} for rid in response_ids],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
self._client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(
|
||||
self._client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
self._poll_interval,
|
||||
self._timeout,
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
async def _evaluate_via_dataset(
|
||||
self,
|
||||
items: Sequence[EvalItem],
|
||||
evaluators: list[str],
|
||||
eval_name: str,
|
||||
) -> EvalResults:
|
||||
"""Evaluate using JSONL dataset upload path."""
|
||||
dicts = [item.to_eval_data(split=item.split_strategy or self._conversation_split) for item in items]
|
||||
has_context = any("context" in d for d in dicts)
|
||||
has_tools = any("tool_definitions" in d for d in dicts)
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
self._client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={
|
||||
"type": "custom",
|
||||
"item_schema": _build_item_schema(has_context=has_context, has_tools=has_tools),
|
||||
"include_sample_schema": True,
|
||||
},
|
||||
testing_criteria=_build_testing_criteria(
|
||||
evaluators,
|
||||
self._model_deployment,
|
||||
include_data_mapping=True,
|
||||
),
|
||||
)
|
||||
|
||||
data_source = {
|
||||
"type": "jsonl",
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": d} for d in dicts],
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
self._client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(
|
||||
self._client,
|
||||
eval_obj.id,
|
||||
run.id,
|
||||
self._poll_interval,
|
||||
self._timeout,
|
||||
provider=self.name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific functions (not part of the Evaluator protocol)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def evaluate_traces(
|
||||
*,
|
||||
evaluators: Sequence[str] | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
model_deployment: str,
|
||||
response_ids: Sequence[str] | None = None,
|
||||
trace_ids: Sequence[str] | None = None,
|
||||
agent_id: str | None = None,
|
||||
lookback_hours: int = 24,
|
||||
eval_name: str = "Agent Framework Trace Eval",
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
) -> EvalResults:
|
||||
"""Evaluate agent behavior from OTel traces or response IDs.
|
||||
|
||||
Foundry-specific function — works with any agent that emits OTel traces
|
||||
to App Insights. Provide *response_ids* for specific responses,
|
||||
*trace_ids* for specific traces, or *agent_id* with *lookback_hours*
|
||||
to evaluate recent activity.
|
||||
|
||||
Args:
|
||||
evaluators: Evaluator names (e.g. ``[FoundryEvals.RELEVANCE]``).
|
||||
Defaults to relevance, coherence, and task_adherence.
|
||||
openai_client: ``AsyncOpenAI`` client. Provide this or *project_client*.
|
||||
project_client: An ``AIProjectClient`` instance.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
response_ids: Evaluate specific Responses API responses.
|
||||
trace_ids: Evaluate specific OTel trace IDs from App Insights.
|
||||
agent_id: Filter traces by agent ID (used with *lookback_hours*).
|
||||
lookback_hours: Hours of trace history to evaluate (default 24).
|
||||
eval_name: Display name for the evaluation.
|
||||
poll_interval: Seconds between status polls.
|
||||
timeout: Maximum seconds to wait for completion.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, result counts, and portal link.
|
||||
|
||||
Example::
|
||||
|
||||
results = await evaluate_traces(
|
||||
response_ids=[response.response_id],
|
||||
evaluators=[FoundryEvals.RELEVANCE],
|
||||
project_client=project_client,
|
||||
model_deployment="gpt-4o",
|
||||
)
|
||||
"""
|
||||
client = _resolve_openai_client(openai_client, project_client)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
if response_ids:
|
||||
foundry = FoundryEvals(
|
||||
openai_client=client,
|
||||
model_deployment=model_deployment,
|
||||
evaluators=resolved_evaluators,
|
||||
poll_interval=poll_interval,
|
||||
timeout=timeout,
|
||||
)
|
||||
return await foundry._evaluate_via_responses( # pyright: ignore[reportPrivateUsage]
|
||||
response_ids,
|
||||
resolved_evaluators,
|
||||
eval_name,
|
||||
)
|
||||
|
||||
if not trace_ids and not agent_id:
|
||||
raise ValueError("Provide at least one of: response_ids, trace_ids, or agent_id")
|
||||
|
||||
trace_source: dict[str, Any] = {
|
||||
"type": "azure_ai_traces",
|
||||
"lookback_hours": lookback_hours,
|
||||
}
|
||||
if trace_ids:
|
||||
trace_source["trace_ids"] = list(trace_ids)
|
||||
if agent_id:
|
||||
trace_source["agent_id"] = agent_id
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={"type": "azure_ai_source", "scenario": "traces"},
|
||||
testing_criteria=_build_testing_criteria(resolved_evaluators, model_deployment),
|
||||
)
|
||||
|
||||
run = await _ensure_async_result(
|
||||
client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=trace_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
|
||||
|
||||
async def evaluate_foundry_target(
|
||||
*,
|
||||
target: dict[str, Any],
|
||||
test_queries: Sequence[str],
|
||||
evaluators: Sequence[str] | None = None,
|
||||
openai_client: AsyncOpenAI | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
model_deployment: str,
|
||||
eval_name: str = "Agent Framework Target Eval",
|
||||
poll_interval: float = 5.0,
|
||||
timeout: float = 600.0,
|
||||
) -> EvalResults:
|
||||
"""Evaluate a Foundry-registered agent or model deployment.
|
||||
|
||||
Foundry invokes the target, captures the output, and evaluates it. Use
|
||||
this for scheduled evals, red teaming, and CI/CD quality gates.
|
||||
|
||||
Args:
|
||||
target: Target configuration dict.
|
||||
test_queries: Queries for Foundry to send to the target.
|
||||
evaluators: Evaluator names.
|
||||
openai_client: ``AsyncOpenAI`` client. Provide this or *project_client*.
|
||||
project_client: An ``AIProjectClient`` instance.
|
||||
model_deployment: Model deployment name for the evaluator LLM judge.
|
||||
eval_name: Display name for the evaluation.
|
||||
poll_interval: Seconds between status polls.
|
||||
timeout: Maximum seconds to wait for completion.
|
||||
|
||||
Returns:
|
||||
``EvalResults`` with status, result counts, and portal link.
|
||||
|
||||
Example::
|
||||
|
||||
results = await evaluate_foundry_target(
|
||||
target={"type": "azure_ai_agent", "name": "my-agent"},
|
||||
test_queries=["Book a flight to Paris"],
|
||||
project_client=project_client,
|
||||
model_deployment="gpt-4o",
|
||||
)
|
||||
"""
|
||||
client = _resolve_openai_client(openai_client, project_client)
|
||||
resolved_evaluators = _resolve_default_evaluators(evaluators)
|
||||
|
||||
eval_obj = await _ensure_async_result(
|
||||
client.evals.create,
|
||||
name=eval_name,
|
||||
data_source_config={
|
||||
"type": "azure_ai_source",
|
||||
"scenario": "target_completions",
|
||||
},
|
||||
testing_criteria=_build_testing_criteria(resolved_evaluators, model_deployment),
|
||||
)
|
||||
|
||||
data_source: dict[str, Any] = {
|
||||
"type": "azure_ai_target_completions",
|
||||
"target": target,
|
||||
"source": {
|
||||
"type": "file_content",
|
||||
"content": [{"item": {"query": q}} for q in test_queries],
|
||||
},
|
||||
}
|
||||
|
||||
run = await _ensure_async_result(
|
||||
client.evals.runs.create,
|
||||
eval_id=eval_obj.id,
|
||||
name=f"{eval_name} Run",
|
||||
data_source=data_source,
|
||||
)
|
||||
|
||||
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout)
|
||||
@@ -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.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-ai-agents>=1.2.0b5,<1.2.0b6",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"aiohttp>=3.7.0,<4",
|
||||
|
||||
@@ -87,8 +87,6 @@ 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,
|
||||
@@ -153,10 +151,6 @@ 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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-cosmos>=4.3.0,<5",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"agent-framework-durabletask",
|
||||
"azure-functions>=1.24.0,<2",
|
||||
"azure-functions-durable>=1.3.1,<2",
|
||||
|
||||
@@ -216,8 +216,8 @@ class BedrockSettings(TypedDict, total=False):
|
||||
|
||||
|
||||
class BedrockChatClient(
|
||||
FunctionInvocationLayer[BedrockChatOptionsT],
|
||||
ChatMiddlewareLayer[BedrockChatOptionsT],
|
||||
FunctionInvocationLayer[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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"boto3>=1.35.0,<2.0.0",
|
||||
"botocore>=1.35.0,<2.0.0",
|
||||
]
|
||||
|
||||
@@ -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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"openai-chatkit>=1.4.1,<2.0.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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"claude-agent-sdk>=0.1.36,<0.1.49",
|
||||
]
|
||||
|
||||
|
||||
@@ -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.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -57,6 +57,27 @@ from ._compaction import (
|
||||
included_messages,
|
||||
included_token_count,
|
||||
)
|
||||
from ._evaluation import (
|
||||
AgentEvalConverter,
|
||||
CheckResult,
|
||||
ConversationSplit,
|
||||
ConversationSplitter,
|
||||
EvalItem,
|
||||
EvalItemResult,
|
||||
EvalResults,
|
||||
EvalScoreResult,
|
||||
Evaluator,
|
||||
ExpectedToolCall,
|
||||
LocalEvaluator,
|
||||
evaluate_agent,
|
||||
evaluate_response,
|
||||
evaluate_workflow,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
tool_call_args_match,
|
||||
tool_called_check,
|
||||
tool_calls_present,
|
||||
)
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
@@ -242,6 +263,7 @@ __all__ = [
|
||||
"USER_AGENT_TELEMETRY_DISABLED_ENV_VAR",
|
||||
"Agent",
|
||||
"AgentContext",
|
||||
"AgentEvalConverter",
|
||||
"AgentExecutor",
|
||||
"AgentExecutorRequest",
|
||||
"AgentExecutorResponse",
|
||||
@@ -268,11 +290,14 @@ __all__ = [
|
||||
"ChatOptions",
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"CheckResult",
|
||||
"CheckpointStorage",
|
||||
"CompactionProvider",
|
||||
"CompactionStrategy",
|
||||
"Content",
|
||||
"ContinuationToken",
|
||||
"ConversationSplit",
|
||||
"ConversationSplitter",
|
||||
"Default",
|
||||
"Edge",
|
||||
"EdgeCondition",
|
||||
@@ -281,7 +306,13 @@ __all__ = [
|
||||
"EmbeddingGenerationOptions",
|
||||
"EmbeddingInputT",
|
||||
"EmbeddingT",
|
||||
"EvalItem",
|
||||
"EvalItemResult",
|
||||
"EvalResults",
|
||||
"EvalScoreResult",
|
||||
"Evaluator",
|
||||
"Executor",
|
||||
"ExpectedToolCall",
|
||||
"FanInEdgeGroup",
|
||||
"FanOutEdgeGroup",
|
||||
"FileCheckpointStorage",
|
||||
@@ -300,6 +331,7 @@ __all__ = [
|
||||
"InMemoryCheckpointStorage",
|
||||
"InMemoryHistoryProvider",
|
||||
"InProcRunnerContext",
|
||||
"LocalEvaluator",
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPWebsocketTool",
|
||||
@@ -379,11 +411,16 @@ __all__ = [
|
||||
"chat_middleware",
|
||||
"create_edge_runner",
|
||||
"detect_media_type_from_base64",
|
||||
"evaluate_agent",
|
||||
"evaluate_response",
|
||||
"evaluate_workflow",
|
||||
"evaluator",
|
||||
"executor",
|
||||
"function_middleware",
|
||||
"handler",
|
||||
"included_messages",
|
||||
"included_token_count",
|
||||
"keyword_check",
|
||||
"load_settings",
|
||||
"map_chat_to_agent_update",
|
||||
"merge_chat_options",
|
||||
@@ -396,6 +433,9 @@ __all__ = [
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"tool",
|
||||
"tool_call_args_match",
|
||||
"tool_called_check",
|
||||
"tool_calls_present",
|
||||
"validate_chat_options",
|
||||
"validate_tool_mode",
|
||||
"validate_tools",
|
||||
|
||||
@@ -639,7 +639,7 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
client=client,
|
||||
name="reasoning-agent",
|
||||
instructions="You are a reasoning assistant.",
|
||||
options={
|
||||
default_options={
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 500,
|
||||
"reasoning_effort": "high", # OpenAI-specific, IDE will autocomplete!
|
||||
@@ -697,6 +697,12 @@ class RawAgent(BaseAgent, Generic[OptionsCoT]): # type: ignore[misc]
|
||||
If both this and a tokenizer on the underlying client are set, this one is used.
|
||||
kwargs: Any additional keyword arguments. Will be stored as ``additional_properties``.
|
||||
"""
|
||||
# Accept 'options' as an alias for 'default_options' so that
|
||||
# Agent(options={"store": False}) works as expected instead of
|
||||
# silently dropping the options into additional_properties.
|
||||
if "options" in kwargs and default_options is None:
|
||||
default_options = kwargs.pop("options")
|
||||
|
||||
opts = dict(default_options) if default_options else {}
|
||||
|
||||
if not isinstance(client, FunctionInvocationLayer) and isinstance(client, BaseChatClient):
|
||||
|
||||
@@ -966,7 +966,16 @@ 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)
|
||||
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(
|
||||
ChatMiddlewareLayer.get_response,
|
||||
FunctionInvocationLayer.get_response,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -902,21 +902,13 @@ 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=input_schema,
|
||||
input_model=tool.inputSchema,
|
||||
additional_properties={
|
||||
_MCP_REMOTE_NAME_KEY: tool.name,
|
||||
_MCP_NORMALIZED_NAME_KEY: normalized_name,
|
||||
|
||||
@@ -742,17 +742,12 @@ 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.
|
||||
|
||||
@@ -829,17 +824,12 @@ 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.
|
||||
|
||||
@@ -902,17 +892,12 @@ 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.
|
||||
|
||||
@@ -995,26 +980,16 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
middleware: Sequence[ChatMiddlewareTypes] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
self.chat_middleware = list(middleware) if middleware else []
|
||||
self._cached_chat_middleware_pipeline: ChatMiddlewarePipeline | None = 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"]
|
||||
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,
|
||||
@@ -1077,8 +1052,14 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]):
|
||||
kwargs["tokenizer"] = tokenizer
|
||||
|
||||
effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
|
||||
call_middleware = effective_client_kwargs.pop("middleware", [])
|
||||
pipeline = self._get_chat_middleware_pipeline(call_middleware) # type: ignore[reportUnknownArgumentType]
|
||||
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"],
|
||||
)
|
||||
if not pipeline.has_middlewares:
|
||||
return super_get_response( # type: ignore[no-any-return]
|
||||
messages=messages,
|
||||
@@ -1153,25 +1134,12 @@ 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,
|
||||
@@ -1242,7 +1210,7 @@ class AgentMiddlewareLayer:
|
||||
)
|
||||
base_middleware_list = categorize_middleware(base_middleware)
|
||||
run_middleware_list = categorize_middleware(middleware)
|
||||
pipeline = self._get_agent_middleware_pipeline([*base_middleware_list["agent"], *run_middleware_list["agent"]])
|
||||
pipeline = AgentMiddlewarePipeline(*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 = (
|
||||
@@ -1424,7 +1392,7 @@ def categorize_middleware(
|
||||
all_middleware: list[Any] = []
|
||||
for source in middleware_sources:
|
||||
if source:
|
||||
if isinstance(source, Sequence) and not isinstance(source, (str, bytes)):
|
||||
if isinstance(source, list):
|
||||
all_middleware.extend(source) # type: ignore
|
||||
else:
|
||||
all_middleware.append(source)
|
||||
|
||||
@@ -63,12 +63,7 @@ if TYPE_CHECKING:
|
||||
from ._clients import SupportsChatGetResponse
|
||||
from ._compaction import CompactionStrategy, TokenizerProtocol
|
||||
from ._mcp import MCPTool
|
||||
from ._middleware import (
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
FunctionInvocationContext,
|
||||
FunctionMiddlewarePipeline,
|
||||
FunctionMiddlewareTypes,
|
||||
)
|
||||
from ._middleware import FunctionInvocationContext, FunctionMiddlewarePipeline, FunctionMiddlewareTypes
|
||||
from ._sessions import AgentSession
|
||||
from ._types import (
|
||||
ChatOptions,
|
||||
@@ -2029,37 +2024,18 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | None = None,
|
||||
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
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_middleware: list[FunctionMiddlewareTypes] = (
|
||||
list(function_middleware) if function_middleware else []
|
||||
)
|
||||
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,
|
||||
@@ -2067,7 +2043,6 @@ 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,
|
||||
@@ -2082,7 +2057,6 @@ 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,
|
||||
@@ -2097,7 +2071,6 @@ 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,
|
||||
@@ -2111,14 +2084,14 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: OptionsCoT | ChatOptions[Any] | None = None,
|
||||
middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None,
|
||||
function_middleware: Sequence[FunctionMiddlewareTypes] | 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 categorize_middleware
|
||||
from ._middleware import FunctionMiddlewarePipeline
|
||||
from ._types import (
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
@@ -2136,21 +2109,16 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
|
||||
)
|
||||
|
||||
effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
|
||||
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", []))
|
||||
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)
|
||||
|
||||
function_middleware_pipeline = self._get_function_middleware_pipeline(runtime_middleware["function"])
|
||||
if runtime_middleware["chat"]:
|
||||
effective_client_kwargs["middleware"] = runtime_middleware["chat"]
|
||||
# ChatMiddleware adds this kwarg
|
||||
function_middleware_pipeline = FunctionMiddlewarePipeline(
|
||||
*(self.function_middleware), *(effective_function_middleware or [])
|
||||
)
|
||||
max_errors = self.function_invocation_configuration.get(
|
||||
"max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST
|
||||
)
|
||||
|
||||
@@ -287,9 +287,12 @@ class AgentExecutor(Executor):
|
||||
self._pending_responses_to_agent = pending_responses_payload
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset the internal cache of the executor."""
|
||||
logger.debug("AgentExecutor %s: Resetting cache", self.id)
|
||||
"""Reset the internal cache and service session state of the executor for a new run."""
|
||||
logger.debug("AgentExecutor %s: Resetting cache and service session", self.id)
|
||||
self._cache.clear()
|
||||
# Clear service_session_id to prevent stale previous_response_id
|
||||
# from leaking between workflow runs (e.g. in evaluate_workflow loops).
|
||||
self._session.service_session_id = None
|
||||
|
||||
async def _run_agent_and_emit(
|
||||
self,
|
||||
|
||||
@@ -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) # type: ignore[reportUnknownVariableType]
|
||||
source = graphviz.Source(dot_content)
|
||||
|
||||
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: # type: ignore
|
||||
except graphviz.backend.execute.ExecutableNotFound as e:
|
||||
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. "
|
||||
|
||||
@@ -345,6 +345,10 @@ class Workflow(DictConvertible):
|
||||
self._runner.reset_iteration_count()
|
||||
self._runner.context.reset_for_new_run()
|
||||
self._state.clear()
|
||||
# Reset all executors (clears cached messages, sessions, etc.)
|
||||
for executor in self.executors.values():
|
||||
if hasattr(executor, "reset"):
|
||||
executor.reset()
|
||||
|
||||
# Store run kwargs in State so executors can access them.
|
||||
# Only overwrite when new kwargs are explicitly provided or state was
|
||||
|
||||
@@ -152,8 +152,8 @@ AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAICha
|
||||
|
||||
class AzureOpenAIChatClient( # type: ignore[misc]
|
||||
AzureOpenAIConfigMixin,
|
||||
FunctionInvocationLayer[AzureOpenAIChatOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIChatOptionsT],
|
||||
FunctionInvocationLayer[AzureOpenAIChatOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIChatOptionsT],
|
||||
RawOpenAIChatClient[AzureOpenAIChatOptionsT],
|
||||
Generic[AzureOpenAIChatOptionsT],
|
||||
|
||||
@@ -51,8 +51,8 @@ AzureOpenAIResponsesOptionsT = TypeVar(
|
||||
|
||||
class AzureOpenAIResponsesClient( # type: ignore[misc]
|
||||
AzureOpenAIConfigMixin,
|
||||
FunctionInvocationLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT],
|
||||
FunctionInvocationLayer[AzureOpenAIResponsesOptionsT],
|
||||
ChatTelemetryLayer[AzureOpenAIResponsesOptionsT],
|
||||
RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT],
|
||||
Generic[AzureOpenAIResponsesOptionsT],
|
||||
|
||||
@@ -362,15 +362,11 @@ def _create_otlp_exporters(
|
||||
if protocol == "grpc":
|
||||
# Import all gRPC exporters
|
||||
try:
|
||||
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._log_exporter import OTLPLogExporter as GRPCLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
|
||||
OTLPMetricExporter as GRPCMetricExporter,
|
||||
)
|
||||
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. "
|
||||
@@ -379,21 +375,21 @@ def _create_otlp_exporters(
|
||||
|
||||
if actual_logs_endpoint:
|
||||
exporters.append(
|
||||
GRPCLogExporter( # type: ignore[reportUnknownArgumentType]
|
||||
GRPCLogExporter(
|
||||
endpoint=actual_logs_endpoint,
|
||||
headers=actual_logs_headers if actual_logs_headers else None,
|
||||
)
|
||||
)
|
||||
if actual_traces_endpoint:
|
||||
exporters.append(
|
||||
GRPCSpanExporter( # type: ignore[reportUnknownArgumentType]
|
||||
GRPCSpanExporter(
|
||||
endpoint=actual_traces_endpoint,
|
||||
headers=actual_traces_headers if actual_traces_headers else None,
|
||||
)
|
||||
)
|
||||
if actual_metrics_endpoint:
|
||||
exporters.append(
|
||||
GRPCMetricExporter( # type: ignore[reportUnknownArgumentType]
|
||||
GRPCMetricExporter(
|
||||
endpoint=actual_metrics_endpoint,
|
||||
headers=actual_metrics_headers if actual_metrics_headers else None,
|
||||
)
|
||||
|
||||
@@ -210,8 +210,8 @@ OpenAIAssistantsOptionsT = TypeVar(
|
||||
|
||||
class OpenAIAssistantsClient( # type: ignore[misc]
|
||||
OpenAIConfigMixin,
|
||||
FunctionInvocationLayer[OpenAIAssistantsOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIAssistantsOptionsT],
|
||||
FunctionInvocationLayer[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
|
||||
from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionMiddlewareTypes
|
||||
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. **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
|
||||
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
|
||||
|
||||
Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied.
|
||||
"""
|
||||
@@ -713,13 +713,9 @@ 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": image_url_obj,
|
||||
"image_url": {"url": content.uri},
|
||||
}
|
||||
case "data" | "uri" if content.has_top_level_media_type("audio"):
|
||||
if content.media_type and "wav" in content.media_type:
|
||||
@@ -776,8 +772,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
|
||||
class OpenAIChatClient( # type: ignore[misc]
|
||||
OpenAIConfigMixin,
|
||||
FunctionInvocationLayer[OpenAIChatOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIChatOptionsT],
|
||||
FunctionInvocationLayer[OpenAIChatOptionsT],
|
||||
ChatTelemetryLayer[OpenAIChatOptionsT],
|
||||
RawOpenAIChatClient[OpenAIChatOptionsT],
|
||||
Generic[OpenAIChatOptionsT],
|
||||
@@ -791,6 +787,7 @@ 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,
|
||||
@@ -804,6 +801,7 @@ 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,
|
||||
@@ -817,6 +815,7 @@ 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,
|
||||
@@ -830,6 +829,7 @@ 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,15 +840,14 @@ 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=effective_client_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
middleware=middleware,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@@ -964,6 +963,10 @@ 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. **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
|
||||
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
|
||||
|
||||
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,
|
||||
FunctionInvocationLayer[OpenAIResponsesOptionsT],
|
||||
ChatMiddlewareLayer[OpenAIResponsesOptionsT],
|
||||
FunctionInvocationLayer[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.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -128,8 +128,8 @@ class MockChatClient:
|
||||
|
||||
|
||||
class MockBaseChatClient(
|
||||
FunctionInvocationLayer[OptionsCoT],
|
||||
ChatMiddlewareLayer[OptionsCoT],
|
||||
FunctionInvocationLayer[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__(middleware=[], **kwargs)
|
||||
super().__init__(function_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,6 +84,7 @@ 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]},
|
||||
client_kwargs={"middleware": [TerminateLoopMiddleware()]},
|
||||
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]},
|
||||
client_kwargs={"middleware": [SelectiveTerminateMiddleware()]},
|
||||
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]},
|
||||
client_kwargs={"middleware": [TerminateLoopMiddleware()]},
|
||||
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(
|
||||
FunctionInvocationLayer,
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationLayer,
|
||||
BaseChatClient,
|
||||
):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(middleware=[])
|
||||
super().__init__(function_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(
|
||||
FunctionInvocationLayer[Any],
|
||||
ChatMiddlewareLayer[Any],
|
||||
FunctionInvocationLayer[Any],
|
||||
ChatTelemetryLayer[Any],
|
||||
_MockBaseChatClient,
|
||||
):
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for evaluator checks and LocalEvaluator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework._evaluation import (
|
||||
CheckResult,
|
||||
EvalItem,
|
||||
ExpectedToolCall,
|
||||
LocalEvaluator,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
tool_call_args_match,
|
||||
tool_calls_present,
|
||||
)
|
||||
from agent_framework._types import Content, Message
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_item(
|
||||
query: str = "What's the weather in Paris?",
|
||||
response: str = "It's sunny and 75°F",
|
||||
expected_output: str | None = None,
|
||||
conversation: list | None = None,
|
||||
tools: list | None = None,
|
||||
context: str | None = None,
|
||||
) -> EvalItem:
|
||||
if conversation is None:
|
||||
conversation = [Message("user", [query]), Message("assistant", [response])]
|
||||
return EvalItem(
|
||||
conversation=conversation,
|
||||
expected_output=expected_output,
|
||||
tools=tools,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1: (query, response) -> result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTier1SimpleChecks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bool_return_true(self):
|
||||
@evaluator
|
||||
def has_temperature(query: str, response: str) -> bool:
|
||||
return "°F" in response
|
||||
|
||||
result = await has_temperature(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.check_name == "has_temperature"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bool_return_false(self):
|
||||
@evaluator
|
||||
def has_celsius(query: str, response: str) -> bool:
|
||||
return "°C" in response
|
||||
|
||||
result = await has_celsius(_make_item())
|
||||
assert result.passed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_float_return_passing(self):
|
||||
@evaluator
|
||||
def length_score(response: str) -> float:
|
||||
return min(len(response) / 10, 1.0)
|
||||
|
||||
result = await length_score(_make_item())
|
||||
assert result.passed is True
|
||||
assert "score=" in result.reason
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_float_return_failing(self):
|
||||
@evaluator
|
||||
def always_low(response: str) -> float:
|
||||
return 0.1
|
||||
|
||||
result = await always_low(_make_item())
|
||||
assert result.passed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_only(self):
|
||||
"""Function with only 'response' param should work."""
|
||||
|
||||
@evaluator
|
||||
def is_short(response: str) -> bool:
|
||||
return len(response) < 1000
|
||||
|
||||
result = await is_short(_make_item())
|
||||
assert result.passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_only(self):
|
||||
"""Function with only 'query' param should work."""
|
||||
|
||||
@evaluator
|
||||
def is_question(query: str) -> bool:
|
||||
return "?" in query
|
||||
|
||||
result = await is_question(_make_item())
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 2: (query, response, expected_output) -> result
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTier2GroundTruth:
|
||||
@pytest.mark.asyncio
|
||||
async def test_exact_match(self):
|
||||
@evaluator
|
||||
def exact_match(response: str, expected_output: str) -> bool:
|
||||
return response.strip() == expected_output.strip()
|
||||
|
||||
item = _make_item(response="42", expected_output="42")
|
||||
assert (await exact_match(item)).passed is True
|
||||
|
||||
item2 = _make_item(response="43", expected_output="42")
|
||||
assert (await exact_match(item2)).passed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expected_output_defaults_to_empty(self):
|
||||
"""When expected_output is None on the item, it should be passed as ''."""
|
||||
|
||||
@evaluator
|
||||
def check_expected(expected_output: str) -> bool:
|
||||
return expected_output == ""
|
||||
|
||||
result = await check_expected(_make_item(expected_output=None))
|
||||
assert result.passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_similarity_score(self):
|
||||
@evaluator
|
||||
def word_overlap(response: str, expected_output: str) -> float:
|
||||
r_words = set(response.lower().split())
|
||||
e_words = set(expected_output.lower().split())
|
||||
if not e_words:
|
||||
return 1.0
|
||||
return len(r_words & e_words) / len(e_words)
|
||||
|
||||
item = _make_item(response="sunny warm day", expected_output="warm sunny afternoon")
|
||||
result = await word_overlap(item)
|
||||
assert result.passed is True # 2/3 overlap ≥ 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 3: full context (conversation, tools, context)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTier3FullContext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_access(self):
|
||||
@evaluator
|
||||
def multi_turn(query: str, response: str, *, conversation: list) -> bool:
|
||||
return len(conversation) >= 2
|
||||
|
||||
item = _make_item(conversation=[Message("user", []), Message("assistant", [])])
|
||||
assert (await multi_turn(item)).passed is True
|
||||
|
||||
item2 = _make_item(conversation=[Message("user", [])])
|
||||
assert (await multi_turn(item2)).passed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_access(self):
|
||||
@evaluator
|
||||
def has_tools(tools: list) -> bool:
|
||||
return len(tools) > 0
|
||||
|
||||
mock_tool = type(
|
||||
"MockTool",
|
||||
(),
|
||||
{"name": "get_weather", "description": "Get weather", "parameters": lambda self: {}},
|
||||
)()
|
||||
item = _make_item(tools=[mock_tool])
|
||||
assert (await has_tools(item)).passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_access(self):
|
||||
@evaluator
|
||||
def grounded(response: str, context: str) -> bool:
|
||||
if not context:
|
||||
return True
|
||||
return any(word in response.lower() for word in context.lower().split())
|
||||
|
||||
item = _make_item(response="It's sunny", context="sunny warm")
|
||||
assert (await grounded(item)).passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_params(self):
|
||||
@evaluator
|
||||
def full_check(
|
||||
query: str,
|
||||
response: str,
|
||||
expected_output: str,
|
||||
conversation: list,
|
||||
tools: list,
|
||||
context: str,
|
||||
) -> bool:
|
||||
return all([query, response, expected_output is not None, isinstance(conversation, list)])
|
||||
|
||||
item = _make_item(expected_output="foo", context="bar")
|
||||
assert (await full_check(item)).passed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Return type coercion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReturnTypeCoercion:
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_with_score(self):
|
||||
@evaluator
|
||||
def scored(response: str) -> dict:
|
||||
return {"score": 0.9, "reason": "good answer"}
|
||||
|
||||
result = await scored(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.reason == "good answer"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_with_score_below_threshold(self):
|
||||
@evaluator
|
||||
def low_scored(response: str) -> dict:
|
||||
return {"score": 0.3}
|
||||
|
||||
result = await low_scored(_make_item())
|
||||
assert result.passed is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_with_custom_threshold(self):
|
||||
@evaluator
|
||||
def custom_threshold(response: str) -> dict:
|
||||
return {"score": 0.3, "threshold": 0.2}
|
||||
|
||||
result = await custom_threshold(_make_item())
|
||||
assert result.passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dict_with_passed(self):
|
||||
@evaluator
|
||||
def explicit_pass(response: str) -> dict:
|
||||
return {"passed": True, "reason": "all good"}
|
||||
|
||||
result = await explicit_pass(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.reason == "all good"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_result_passthrough(self):
|
||||
@evaluator
|
||||
def returns_check_result(response: str) -> CheckResult:
|
||||
return CheckResult(True, "direct result", "custom")
|
||||
|
||||
result = await returns_check_result(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.reason == "direct result"
|
||||
assert result.check_name == "custom"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_return_type(self):
|
||||
@evaluator
|
||||
def bad_return(response: str) -> str:
|
||||
return "oops"
|
||||
|
||||
with pytest.raises(TypeError, match="unsupported type"):
|
||||
await bad_return(_make_item())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_int_return(self):
|
||||
@evaluator
|
||||
def int_score(response: str) -> int:
|
||||
return 1
|
||||
|
||||
result = await int_score(_make_item())
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Decorator variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDecoratorVariants:
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_no_parens(self):
|
||||
@evaluator
|
||||
def my_check(response: str) -> bool:
|
||||
return True
|
||||
|
||||
assert (await my_check(_make_item())).passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decorator_with_name(self):
|
||||
@evaluator(name="custom_name")
|
||||
def my_check(response: str) -> bool:
|
||||
return True
|
||||
|
||||
assert my_check.__name__ == "custom_name"
|
||||
result = await my_check(_make_item())
|
||||
assert result.check_name == "custom_name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_direct_call(self):
|
||||
def raw_fn(query: str, response: str) -> bool:
|
||||
return len(response) > 0
|
||||
|
||||
check = evaluator(raw_fn, name="direct")
|
||||
result = await check(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.check_name == "direct"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_required_param_raises(self):
|
||||
@evaluator
|
||||
def bad_params(query: str, unknown_param: str) -> bool:
|
||||
return True
|
||||
|
||||
with pytest.raises(TypeError, match="unknown required parameter"):
|
||||
await bad_params(_make_item())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_optional_param_ok(self):
|
||||
@evaluator
|
||||
def optional_unknown(query: str, foo: str = "default") -> bool:
|
||||
return foo == "default"
|
||||
|
||||
result = await optional_unknown(_make_item())
|
||||
assert result.passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_function_works_with_evaluator(self):
|
||||
"""Using an async function with @evaluator should work."""
|
||||
|
||||
@evaluator
|
||||
async def async_fn(response: str) -> bool:
|
||||
return True
|
||||
|
||||
result = async_fn(_make_item())
|
||||
# Should return an awaitable
|
||||
assert inspect.isawaitable(result)
|
||||
check_result = await result
|
||||
assert check_result.passed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration with LocalEvaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLocalEvaluatorIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_checks(self):
|
||||
"""Function evaluators mix with built-in checks in LocalEvaluator."""
|
||||
|
||||
@evaluator
|
||||
def length_ok(response: str) -> bool:
|
||||
return len(response) > 5
|
||||
|
||||
local = LocalEvaluator(
|
||||
keyword_check("sunny"),
|
||||
length_ok,
|
||||
)
|
||||
items = [_make_item()]
|
||||
results = await local.evaluate(items, eval_name="mixed test")
|
||||
|
||||
assert results.status == "completed"
|
||||
assert results.result_counts["passed"] == 1
|
||||
assert results.result_counts["failed"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluator_failure_counted(self):
|
||||
@evaluator
|
||||
def always_fail(response: str) -> bool:
|
||||
return False
|
||||
|
||||
local = LocalEvaluator(always_fail)
|
||||
results = await local.evaluate([_make_item()])
|
||||
|
||||
assert results.result_counts["failed"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_evaluators(self):
|
||||
@evaluator
|
||||
def check_a(response: str) -> float:
|
||||
return 0.9
|
||||
|
||||
@evaluator
|
||||
def check_b(query: str, response: str, expected_output: str) -> bool:
|
||||
return True
|
||||
|
||||
@evaluator(name="check_c")
|
||||
def check_c(response: str, conversation: list) -> dict:
|
||||
return {"score": 0.8, "reason": "looks good"}
|
||||
|
||||
local = LocalEvaluator(check_a, check_b, check_c)
|
||||
results = await local.evaluate([_make_item(expected_output="test")])
|
||||
|
||||
assert results.result_counts["passed"] == 1
|
||||
assert "check_a" in results.per_evaluator
|
||||
assert "check_b" in results.per_evaluator
|
||||
assert "check_c" in results.per_evaluator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async evaluator (via @evaluator which handles async automatically)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncFunctionEvaluator:
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_evaluator_in_local(self):
|
||||
@evaluator
|
||||
async def async_check(query: str, response: str) -> bool:
|
||||
return len(response) > 0
|
||||
|
||||
local = LocalEvaluator(async_check)
|
||||
results = await local.evaluate([_make_item()])
|
||||
assert results.result_counts["passed"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_with_name(self):
|
||||
@evaluator(name="named_async")
|
||||
async def my_async(response: str) -> float:
|
||||
return 0.75
|
||||
|
||||
result = await my_async(_make_item())
|
||||
assert result.passed is True
|
||||
assert result.check_name == "named_async"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-wrapping bare checks in evaluate_agent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoWrapEvalChecks:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_check_in_evaluators_list(self):
|
||||
"""Bare EvalCheck callables are auto-wrapped in LocalEvaluator."""
|
||||
from agent_framework._evaluation import _run_evaluators
|
||||
|
||||
@evaluator
|
||||
def is_long(response: str) -> bool:
|
||||
return len(response.split()) > 2
|
||||
|
||||
items = [_make_item(response="It is sunny and warm today")]
|
||||
results = await _run_evaluators(is_long, items, eval_name="test")
|
||||
assert len(results) == 1
|
||||
assert results[0].result_counts["passed"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_evaluators_and_checks(self):
|
||||
"""Mix of Evaluator instances and bare checks works."""
|
||||
from agent_framework._evaluation import _run_evaluators
|
||||
|
||||
@evaluator
|
||||
def has_words(response: str) -> bool:
|
||||
return len(response.split()) > 0
|
||||
|
||||
local = LocalEvaluator(keyword_check("sunny"))
|
||||
|
||||
items = [_make_item(response="It is sunny")]
|
||||
results = await _run_evaluators([local, has_words], items, eval_name="test")
|
||||
assert len(results) == 2
|
||||
assert all(r.result_counts["passed"] == 1 for r in results)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adjacent_checks_grouped(self):
|
||||
"""Adjacent bare checks are grouped into a single LocalEvaluator."""
|
||||
from agent_framework._evaluation import _run_evaluators
|
||||
|
||||
@evaluator
|
||||
def check_a(response: str) -> bool:
|
||||
return True
|
||||
|
||||
@evaluator
|
||||
def check_b(response: str) -> bool:
|
||||
return True
|
||||
|
||||
items = [_make_item()]
|
||||
results = await _run_evaluators([check_a, check_b], items, eval_name="test")
|
||||
# Two adjacent checks → one LocalEvaluator → one result
|
||||
assert len(results) == 1
|
||||
assert results[0].result_counts["passed"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Expected Tool Calls
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_tool_call_item(
|
||||
calls: list[tuple[str, dict | None]],
|
||||
expected: list[ExpectedToolCall] | None = None,
|
||||
) -> EvalItem:
|
||||
"""Build an EvalItem with tool calls in the conversation."""
|
||||
msgs: list[Message] = [Message("user", ["Do something"])]
|
||||
for name, args in calls:
|
||||
msgs.append(Message("assistant", [Content.from_function_call("call_" + name, name, arguments=args)]))
|
||||
msgs.append(Message("assistant", ["Done"]))
|
||||
return EvalItem(conversation=msgs, expected_tool_calls=expected)
|
||||
|
||||
|
||||
class TestExpectedToolCallType:
|
||||
def test_name_only(self):
|
||||
tc = ExpectedToolCall("get_weather")
|
||||
assert tc.name == "get_weather"
|
||||
assert tc.arguments is None
|
||||
|
||||
def test_name_and_args(self):
|
||||
tc = ExpectedToolCall("get_weather", {"location": "NYC"})
|
||||
assert tc.name == "get_weather"
|
||||
assert tc.arguments == {"location": "NYC"}
|
||||
|
||||
|
||||
class TestToolCallsPresent:
|
||||
def test_all_present(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", None), ("get_news", None)],
|
||||
expected=[ExpectedToolCall("get_weather"), ExpectedToolCall("get_news")],
|
||||
)
|
||||
result = tool_calls_present(item)
|
||||
assert result.passed is True
|
||||
assert result.check_name == "tool_calls_present"
|
||||
|
||||
def test_missing_tool(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", None)],
|
||||
expected=[ExpectedToolCall("get_weather"), ExpectedToolCall("get_news")],
|
||||
)
|
||||
result = tool_calls_present(item)
|
||||
assert result.passed is False
|
||||
assert "get_news" in result.reason
|
||||
|
||||
def test_extras_ok(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", None), ("get_news", None), ("get_stock", None)],
|
||||
expected=[ExpectedToolCall("get_weather")],
|
||||
)
|
||||
result = tool_calls_present(item)
|
||||
assert result.passed is True
|
||||
|
||||
def test_no_expected(self):
|
||||
item = _make_tool_call_item(calls=[("get_weather", None)])
|
||||
result = tool_calls_present(item)
|
||||
assert result.passed is True
|
||||
assert "No expected" in result.reason
|
||||
|
||||
|
||||
class TestToolCallArgsMatch:
|
||||
def test_name_only_match(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", {"location": "NYC"})],
|
||||
expected=[ExpectedToolCall("get_weather")],
|
||||
)
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is True
|
||||
|
||||
def test_args_exact_match(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", {"location": "NYC", "units": "fahrenheit"})],
|
||||
expected=[ExpectedToolCall("get_weather", {"location": "NYC"})],
|
||||
)
|
||||
# Subset match — extra "units" key is OK
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is True
|
||||
|
||||
def test_args_mismatch(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_weather", {"location": "LA"})],
|
||||
expected=[ExpectedToolCall("get_weather", {"location": "NYC"})],
|
||||
)
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is False
|
||||
assert "args mismatch" in result.reason
|
||||
|
||||
def test_tool_not_called(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[("get_news", None)],
|
||||
expected=[ExpectedToolCall("get_weather", {"location": "NYC"})],
|
||||
)
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is False
|
||||
assert "not called" in result.reason
|
||||
|
||||
def test_multiple_expected(self):
|
||||
item = _make_tool_call_item(
|
||||
calls=[
|
||||
("get_weather", {"location": "NYC"}),
|
||||
("book_flight", {"destination": "LA", "date": "tomorrow"}),
|
||||
],
|
||||
expected=[
|
||||
ExpectedToolCall("get_weather", {"location": "NYC"}),
|
||||
ExpectedToolCall("book_flight", {"destination": "LA"}),
|
||||
],
|
||||
)
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is True
|
||||
|
||||
def test_no_expected(self):
|
||||
item = _make_tool_call_item(calls=[("get_weather", None)])
|
||||
result = tool_call_args_match(item)
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
class TestExpectedToolCallsFieldInjection:
|
||||
"""Test that @evaluator can receive expected_tool_calls via parameter injection."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection(self):
|
||||
@evaluator
|
||||
def check_tools(expected_tool_calls: list) -> bool:
|
||||
return len(expected_tool_calls) == 2
|
||||
|
||||
item = _make_tool_call_item(
|
||||
calls=[],
|
||||
expected=[ExpectedToolCall("a"), ExpectedToolCall("b")],
|
||||
)
|
||||
result = await check_tools(item)
|
||||
assert result.passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_empty_default(self):
|
||||
@evaluator
|
||||
def check_tools(expected_tool_calls: list) -> bool:
|
||||
return len(expected_tool_calls) == 0
|
||||
|
||||
item = _make_tool_call_item(calls=[])
|
||||
result = await check_tools(item)
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-item results (auditing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPerItemResults:
|
||||
"""LocalEvaluator should produce per-item EvalItemResult with query/response."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_populated_with_query_and_response(self):
|
||||
@evaluator
|
||||
def is_sunny(response: str) -> bool:
|
||||
return "sunny" in response.lower()
|
||||
|
||||
item = _make_item(query="Weather?", response="It's sunny!")
|
||||
local = LocalEvaluator(is_sunny)
|
||||
results = await local.evaluate([item])
|
||||
|
||||
assert len(results.items) == 1
|
||||
ri = results.items[0]
|
||||
assert ri.item_id == "0"
|
||||
assert ri.status == "pass"
|
||||
assert ri.input_text == "Weather?"
|
||||
assert ri.output_text == "It's sunny!"
|
||||
assert len(ri.scores) == 1
|
||||
assert ri.scores[0].name == "is_sunny"
|
||||
assert ri.scores[0].passed is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_items_populated_on_failure(self):
|
||||
@evaluator
|
||||
def always_fail(response: str) -> bool:
|
||||
return False
|
||||
|
||||
item = _make_item(query="Hello", response="World")
|
||||
local = LocalEvaluator(always_fail)
|
||||
results = await local.evaluate([item])
|
||||
|
||||
assert len(results.items) == 1
|
||||
ri = results.items[0]
|
||||
assert ri.status == "fail"
|
||||
assert ri.input_text == "Hello"
|
||||
assert ri.output_text == "World"
|
||||
assert ri.scores[0].passed is False
|
||||
assert ri.scores[0].score == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_items_indexed(self):
|
||||
@evaluator
|
||||
def pass_all(response: str) -> bool:
|
||||
return True
|
||||
|
||||
items = [
|
||||
_make_item(query="Q1", response="R1"),
|
||||
_make_item(query="Q2", response="R2"),
|
||||
]
|
||||
local = LocalEvaluator(pass_all)
|
||||
results = await local.evaluate(items)
|
||||
|
||||
assert len(results.items) == 2
|
||||
assert results.items[0].item_id == "0"
|
||||
assert results.items[0].input_text == "Q1"
|
||||
assert results.items[0].output_text == "R1"
|
||||
assert results.items[1].item_id == "1"
|
||||
assert results.items[1].input_text == "Q2"
|
||||
assert results.items[1].output_text == "R2"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# num_repetitions validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNumRepetitions:
|
||||
"""Tests for the num_repetitions parameter on evaluate_agent."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_num_repetitions_validation_rejects_zero(self):
|
||||
from agent_framework._evaluation import evaluate_agent
|
||||
|
||||
with pytest.raises(ValueError, match="num_repetitions must be >= 1"):
|
||||
await evaluate_agent(
|
||||
queries=["Hello"],
|
||||
evaluators=LocalEvaluator(keyword_check("hello")),
|
||||
num_repetitions=0,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_num_repetitions_validation_rejects_negative(self):
|
||||
from agent_framework._evaluation import evaluate_agent
|
||||
|
||||
with pytest.raises(ValueError, match="num_repetitions must be >= 1"):
|
||||
await evaluate_agent(
|
||||
queries=["Hello"],
|
||||
evaluators=LocalEvaluator(keyword_check("hello")),
|
||||
num_repetitions=-1,
|
||||
)
|
||||
@@ -2042,100 +2042,6 @@ 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,7 +28,6 @@ from agent_framework._middleware import (
|
||||
FunctionMiddleware,
|
||||
FunctionMiddlewarePipeline,
|
||||
MiddlewareTermination,
|
||||
categorize_middleware,
|
||||
)
|
||||
from agent_framework._tools import FunctionTool
|
||||
|
||||
@@ -1682,49 +1681,3 @@ 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,26 +697,6 @@ 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:
|
||||
@@ -1989,77 +1969,6 @@ 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,10 +274,7 @@ class TestChatMiddleware:
|
||||
|
||||
# First call with run-level middleware
|
||||
messages = [Message(role="user", text="first message")]
|
||||
response1 = await chat_client_base.get_response(
|
||||
messages,
|
||||
client_kwargs={"middleware": [counting_middleware]},
|
||||
)
|
||||
response1 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
|
||||
assert response1 is not None
|
||||
assert execution_count["count"] == 1
|
||||
|
||||
@@ -289,10 +286,7 @@ 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,
|
||||
client_kwargs={"middleware": [counting_middleware]},
|
||||
)
|
||||
response3 = await chat_client_base.get_response(messages, middleware=[counting_middleware])
|
||||
assert response3 is not None
|
||||
assert execution_count["count"] == 2 # Should be 2 now
|
||||
|
||||
@@ -341,81 +335,6 @@ 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:
|
||||
@@ -531,9 +450,7 @@ 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]},
|
||||
client_kwargs={"middleware": [run_level_function_middleware]},
|
||||
messages, options={"tools": [sample_tool_wrapped]}, middleware=[run_level_function_middleware]
|
||||
)
|
||||
|
||||
# Verify response
|
||||
@@ -546,156 +463,3 @@ 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",
|
||||
]
|
||||
|
||||
@@ -2437,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 (FunctionInvocationLayer, ChatMiddlewareLayer,
|
||||
When using the correct layer ordering (ChatMiddlewareLayer, FunctionInvocationLayer,
|
||||
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)
|
||||
@@ -2454,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 ChatMiddlewareLayer BEFORE ChatTelemetryLayer
|
||||
# This ensures each inner LLM call traverses chat middleware and still gets its own telemetry span
|
||||
# Correct layer ordering: FunctionInvocationLayer BEFORE ChatTelemetryLayer
|
||||
# This ensures each inner LLM call gets its own telemetry span
|
||||
class MockChatClientWithLayers(
|
||||
FunctionInvocationLayer,
|
||||
ChatMiddlewareLayer,
|
||||
FunctionInvocationLayer,
|
||||
ChatTelemetryLayer,
|
||||
BaseChatClient,
|
||||
):
|
||||
|
||||
@@ -462,99 +462,6 @@ def test_prepare_content_for_openai_data_content_image(
|
||||
assert result["input_audio"]["format"] == "mp3"
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_image_url_detail(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
"""Test _prepare_content_for_openai includes the detail field in image_url when specified."""
|
||||
client = OpenAIChatClient()
|
||||
|
||||
# Test image with detail set to "high"
|
||||
image_with_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": "high"},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_with_detail) # type: ignore
|
||||
|
||||
assert result["type"] == "image_url"
|
||||
assert result["image_url"]["url"] == "https://example.com/image.png"
|
||||
assert result["image_url"]["detail"] == "high"
|
||||
|
||||
# Test image with detail set to "low"
|
||||
image_low_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": "low"},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_low_detail) # type: ignore
|
||||
|
||||
assert result["image_url"]["detail"] == "low"
|
||||
|
||||
# Test image with detail set to "auto"
|
||||
image_auto_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": "auto"},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_auto_detail) # type: ignore
|
||||
|
||||
assert result["image_url"]["detail"] == "auto"
|
||||
|
||||
# Test image without detail should not include it
|
||||
image_no_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_no_detail) # type: ignore
|
||||
|
||||
assert result["type"] == "image_url"
|
||||
assert result["image_url"]["url"] == "https://example.com/image.png"
|
||||
assert "detail" not in result["image_url"]
|
||||
|
||||
# Test image with a future/unknown string detail value should pass it through
|
||||
image_future_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": "ultra"},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_future_detail) # type: ignore
|
||||
|
||||
assert result["type"] == "image_url"
|
||||
assert result["image_url"]["url"] == "https://example.com/image.png"
|
||||
assert result["image_url"]["detail"] == "ultra"
|
||||
|
||||
# Test image with data URI should include detail
|
||||
image_data_uri = Content.from_uri(
|
||||
uri="data:image/png;base64,iVBORw0KGgo",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": "high"},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_data_uri) # type: ignore
|
||||
|
||||
assert result["type"] == "image_url"
|
||||
assert result["image_url"]["url"] == "data:image/png;base64,iVBORw0KGgo"
|
||||
assert result["image_url"]["detail"] == "high"
|
||||
|
||||
# Test image with non-string detail value should not include it
|
||||
image_non_string_detail = Content.from_uri(
|
||||
uri="https://example.com/image.png",
|
||||
media_type="image/png",
|
||||
additional_properties={"detail": 123},
|
||||
)
|
||||
|
||||
result = client._prepare_content_for_openai(image_non_string_detail) # type: ignore
|
||||
|
||||
assert result["type"] == "image_url"
|
||||
assert result["image_url"]["url"] == "https://example.com/image.png"
|
||||
assert "detail" not in result["image_url"]
|
||||
|
||||
|
||||
def test_prepare_content_for_openai_document_file_mapping(
|
||||
openai_unit_test_env: dict[str, str],
|
||||
) -> None:
|
||||
|
||||
@@ -460,10 +460,10 @@ async def test_run_request_with_full_history_clears_service_session_id() -> None
|
||||
assert spy_agent._captured_service_session_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_from_response_preserves_service_session_id() -> None:
|
||||
"""from_response hands off a prior agent's full conversation to the next executor.
|
||||
The receiving executor's service_session_id is preserved so the API can continue
|
||||
the conversation using previous_response_id."""
|
||||
async def test_from_response_clears_service_session_id_on_new_run() -> None:
|
||||
"""service_session_id set before a workflow run is cleared by the executor reset
|
||||
that happens at the start of each run, preventing stale previous_response_id
|
||||
from leaking between runs."""
|
||||
tool_agent = _ToolHistoryAgent(id="tool_agent2", name="ToolAgent", summary_text="Done.")
|
||||
tool_exec = AgentExecutor(tool_agent, id="tool_agent2")
|
||||
|
||||
@@ -477,4 +477,6 @@ async def test_from_response_preserves_service_session_id() -> None:
|
||||
result = await wf.run("start")
|
||||
assert result.get_outputs() is not None
|
||||
|
||||
assert spy_agent._captured_service_session_id == "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
|
||||
# service_session_id is cleared at the start of run() to prevent stale
|
||||
# previous_response_id from causing "No tool output found" errors on re-runs.
|
||||
assert spy_agent._captured_service_session_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
"uvicorn[standard]>=0.30.0,<0.42.0"
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"durabletask>=1.3.0,<2",
|
||||
"durabletask-azuremanaged>=1.3.0,<2",
|
||||
"python-dateutil>=2.8.0,<3",
|
||||
|
||||
+1
-1
@@ -130,8 +130,8 @@ class FoundryLocalSettings(TypedDict, total=False):
|
||||
|
||||
|
||||
class FoundryLocalClient(
|
||||
FunctionInvocationLayer[FoundryLocalChatOptionsT],
|
||||
ChatMiddlewareLayer[FoundryLocalChatOptionsT],
|
||||
FunctionInvocationLayer[FoundryLocalChatOptionsT],
|
||||
ChatTelemetryLayer[FoundryLocalChatOptionsT],
|
||||
RawOpenAIChatClient[FoundryLocalChatOptionsT],
|
||||
Generic[FoundryLocalChatOptionsT],
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"foundry-local-sdk>=0.5.1,<0.5.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -458,43 +458,6 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
|
||||
raw_representation=event,
|
||||
)
|
||||
queue.put_nowait(update)
|
||||
elif event.type == SessionEventType.TOOL_EXECUTION_START:
|
||||
tool_call_id = getattr(event.data, "tool_call_id", None) or ""
|
||||
tool_name = getattr(event.data, "tool_name", None) or ""
|
||||
arguments = getattr(event.data, "arguments", None)
|
||||
fc = Content.from_function_call(
|
||||
call_id=tool_call_id,
|
||||
name=tool_name,
|
||||
arguments=arguments,
|
||||
raw_representation=event.data,
|
||||
)
|
||||
update = AgentResponseUpdate(
|
||||
role="assistant",
|
||||
contents=[fc],
|
||||
raw_representation=event,
|
||||
)
|
||||
queue.put_nowait(update)
|
||||
elif event.type == SessionEventType.TOOL_EXECUTION_COMPLETE:
|
||||
tool_call_id = getattr(event.data, "tool_call_id", None) or ""
|
||||
result_obj = getattr(event.data, "result", None)
|
||||
result_text = getattr(result_obj, "content", "") if result_obj else ""
|
||||
success = getattr(event.data, "success", None)
|
||||
error_val = getattr(event.data, "error", None)
|
||||
exception = None
|
||||
if success is False and error_val is not None:
|
||||
exception = error_val.message if hasattr(error_val, "message") else str(error_val)
|
||||
fr = Content.from_function_result(
|
||||
call_id=tool_call_id,
|
||||
result=result_text or "",
|
||||
exception=exception,
|
||||
raw_representation=event.data,
|
||||
)
|
||||
update = AgentResponseUpdate(
|
||||
role="tool",
|
||||
contents=[fr],
|
||||
raw_representation=event,
|
||||
)
|
||||
queue.put_nowait(update)
|
||||
elif event.type == SessionEventType.SESSION_IDLE:
|
||||
queue.put_nowait(None)
|
||||
elif event.type == SessionEventType.SESSION_ERROR:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from agent_framework import (
|
||||
Message,
|
||||
)
|
||||
from agent_framework.exceptions import AgentException
|
||||
from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType
|
||||
from copilot.generated.session_events import Data, SessionEvent, SessionEventType
|
||||
from copilot.types import ToolInvocation, ToolResult
|
||||
|
||||
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
|
||||
@@ -463,376 +463,6 @@ class TestGitHubCopilotAgentRunStreaming:
|
||||
assert agent._started is True # type: ignore
|
||||
mock_client.start.assert_called_once()
|
||||
|
||||
async def test_run_streaming_tool_execution_start(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that TOOL_EXECUTION_START events produce function_call content."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_abc123"
|
||||
tool_event_data.tool_name = "get_weather"
|
||||
tool_event_data.arguments = {"city": "Seattle"}
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_START,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("What's the weather?", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "assistant"
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_call"
|
||||
assert content.call_id == "call_abc123"
|
||||
assert content.name == "get_weather"
|
||||
assert content.arguments == {"city": "Seattle"}
|
||||
assert content.raw_representation is tool_event_data
|
||||
|
||||
async def test_run_streaming_tool_execution_complete(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that TOOL_EXECUTION_COMPLETE events produce function_result content."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_abc123"
|
||||
tool_event_data.result = Result(content="Sunny, 72°F")
|
||||
tool_event_data.success = True
|
||||
tool_event_data.error = None
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("What's the weather?", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
assert responses[0].role == "tool"
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == "call_abc123"
|
||||
assert content.result == "Sunny, 72°F"
|
||||
assert content.exception is None
|
||||
assert content.raw_representation is tool_event_data
|
||||
|
||||
async def test_run_streaming_tool_execution_missing_fields(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that missing tool fields fall back to empty strings."""
|
||||
tool_event_data = MagicMock(spec=[]) # No attributes
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_START,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_call"
|
||||
assert content.call_id == ""
|
||||
assert content.name == ""
|
||||
assert content.arguments is None
|
||||
|
||||
async def test_run_streaming_tool_result_none(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that a tool result with None result object produces empty string."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_xyz"
|
||||
tool_event_data.result = None
|
||||
tool_event_data.success = True
|
||||
tool_event_data.error = None
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == "call_xyz"
|
||||
assert content.result == ""
|
||||
assert content.exception is None
|
||||
|
||||
async def test_run_streaming_tool_execution_failure(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that a failed tool result surfaces the error as exception."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_fail"
|
||||
tool_event_data.result = Result(content="Error: connection timeout")
|
||||
tool_event_data.success = False
|
||||
tool_event_data.error = ErrorClass(message="connection timeout")
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == "call_fail"
|
||||
assert content.result == "Error: connection timeout"
|
||||
assert content.exception == "connection timeout"
|
||||
|
||||
async def test_run_streaming_tool_execution_failure_string_error(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that a failed tool result with a string error is surfaced."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_fail2"
|
||||
tool_event_data.result = Result(content="")
|
||||
tool_event_data.success = False
|
||||
tool_event_data.error = "something went wrong"
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == "call_fail2"
|
||||
assert content.exception == "something went wrong"
|
||||
|
||||
async def test_run_streaming_tool_execution_success_with_error_field(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that a successful tool result with error field does not propagate exception."""
|
||||
tool_event_data = MagicMock()
|
||||
tool_event_data.tool_call_id = "call_ok"
|
||||
tool_event_data.result = Result(content="partial result")
|
||||
tool_event_data.success = True
|
||||
tool_event_data.error = "some warning"
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == "call_ok"
|
||||
assert content.result == "partial result"
|
||||
assert content.exception is None
|
||||
|
||||
async def test_run_streaming_tool_complete_missing_fields(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test that missing fields on TOOL_EXECUTION_COMPLETE fall back to defaults."""
|
||||
tool_event_data = MagicMock(spec=[]) # No attributes
|
||||
|
||||
tool_event = SessionEvent(
|
||||
data=tool_event_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
handler(tool_event)
|
||||
handler(session_idle_event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("Hello", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 1
|
||||
content = responses[0].contents[0]
|
||||
assert content.type == "function_result"
|
||||
assert content.call_id == ""
|
||||
assert content.result == ""
|
||||
assert content.exception is None
|
||||
|
||||
async def test_run_streaming_tool_call_and_result_sequence(
|
||||
self,
|
||||
mock_client: MagicMock,
|
||||
mock_session: MagicMock,
|
||||
assistant_delta_event: SessionEvent,
|
||||
session_idle_event: SessionEvent,
|
||||
) -> None:
|
||||
"""Test a full streaming sequence: text delta, tool call, tool result, text delta."""
|
||||
# Tool call event
|
||||
call_data = MagicMock()
|
||||
call_data.tool_call_id = "call_001"
|
||||
call_data.tool_name = "search"
|
||||
call_data.arguments = {"query": "weather"}
|
||||
tool_call_event = SessionEvent(
|
||||
data=call_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_START,
|
||||
)
|
||||
|
||||
# Tool result event
|
||||
result_data = MagicMock()
|
||||
result_data.tool_call_id = "call_001"
|
||||
result_data.result = Result(content="72°F and sunny")
|
||||
result_data.success = True
|
||||
result_data.error = None
|
||||
tool_result_event = SessionEvent(
|
||||
data=result_data,
|
||||
id=uuid4(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
type=SessionEventType.TOOL_EXECUTION_COMPLETE,
|
||||
)
|
||||
|
||||
# Final text delta
|
||||
final_delta = create_session_event(
|
||||
SessionEventType.ASSISTANT_MESSAGE_DELTA,
|
||||
delta_content="The weather is sunny.",
|
||||
message_id="msg-2",
|
||||
)
|
||||
|
||||
events = [assistant_delta_event, tool_call_event, tool_result_event, final_delta, session_idle_event]
|
||||
|
||||
def mock_on(handler: Any) -> Any:
|
||||
for event in events:
|
||||
handler(event)
|
||||
return lambda: None
|
||||
|
||||
mock_session.on = mock_on
|
||||
|
||||
agent = GitHubCopilotAgent(client=mock_client)
|
||||
responses: list[AgentResponseUpdate] = []
|
||||
async for update in agent.run("What's the weather?", stream=True):
|
||||
responses.append(update)
|
||||
|
||||
assert len(responses) == 4
|
||||
assert responses[0].role == "assistant"
|
||||
assert responses[0].contents[0].type == "text"
|
||||
assert responses[1].role == "assistant"
|
||||
assert responses[1].contents[0].type == "function_call"
|
||||
assert responses[2].role == "tool"
|
||||
assert responses[2].contents[0].type == "function_result"
|
||||
assert responses[3].role == "assistant"
|
||||
assert responses[3].contents[0].type == "text"
|
||||
|
||||
|
||||
class TestGitHubCopilotAgentSessionManagement:
|
||||
"""Test cases for session management."""
|
||||
|
||||
@@ -273,7 +273,7 @@ def _load_gaia_local(repo_dir: Path, wanted_levels: list[int] | None = None, max
|
||||
|
||||
for p in parquet_files:
|
||||
try:
|
||||
import pyarrow.parquet as pq # type: ignore[reportMissingImports]
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
pq_any = cast(Any, pq)
|
||||
table: Any = pq_any.read_table(p)
|
||||
|
||||
@@ -7,8 +7,8 @@ from __future__ import annotations
|
||||
import importlib.metadata
|
||||
|
||||
from agent_framework.observability import enable_instrumentation
|
||||
from agentlightning.tracer import ( # type: ignore[reportMissingImports]
|
||||
AgentOpsTracer, # type: ignore[reportMissingImports, import-not-found]
|
||||
from agentlightning.tracer import (
|
||||
AgentOpsTracer, # pyright: ignore[reportMissingImports] # type: ignore[import-not-found]
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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 = [
|
||||
"Programming Language :: Python :: 3.14",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"mem0ai>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -285,8 +285,8 @@ logger = logging.getLogger("agent_framework.ollama")
|
||||
|
||||
|
||||
class OllamaChatClient(
|
||||
FunctionInvocationLayer[OllamaChatOptionsT],
|
||||
ChatMiddlewareLayer[OllamaChatOptionsT],
|
||||
FunctionInvocationLayer[OllamaChatOptionsT],
|
||||
ChatTelemetryLayer[OllamaChatOptionsT],
|
||||
BaseChatClient[OllamaChatOptionsT],
|
||||
):
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://learn.microsoft.com/en-us/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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"ollama>=0.5.3,<0.5.4",
|
||||
]
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -33,7 +33,7 @@ from agent_framework_orchestrations._handoff import (
|
||||
from agent_framework_orchestrations._orchestrator_helpers import clean_conversation_for_handoff
|
||||
|
||||
|
||||
class MockChatClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
"""Mock chat client for testing handoff workflows."""
|
||||
|
||||
def __init__(
|
||||
@@ -134,7 +134,7 @@ class MockHandoffAgent(Agent):
|
||||
super().__init__(client=MockChatClient(name=name, handoff_to=handoff_to), name=name, id=name)
|
||||
|
||||
|
||||
class ContextAwareRefundClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class ContextAwareRefundClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
"""Mock client that expects prior user context to remain available on resume."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -298,7 +298,7 @@ async def test_tool_approval_responses_are_not_replayed_from_history() -> None:
|
||||
execution_count += 1
|
||||
return "ok"
|
||||
|
||||
class ApprovalReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class ApprovalReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
@@ -383,7 +383,7 @@ async def test_handoff_resume_preserves_approval_function_call_for_stateless_run
|
||||
def submit_refund() -> str:
|
||||
return "ok"
|
||||
|
||||
class StrictStatelessApprovalClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class StrictStatelessApprovalClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
@@ -475,7 +475,7 @@ async def test_handoff_resume_preserves_approval_function_call_for_stateless_run
|
||||
async def test_handoff_replay_serializes_handoff_function_results() -> None:
|
||||
"""Returning to the same agent must not replay dict tool outputs."""
|
||||
|
||||
class ReplaySafeHandoffClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class ReplaySafeHandoffClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self, name: str, handoff_sequence: list[str | None]) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
@@ -550,7 +550,7 @@ async def test_handoff_resume_preserves_approved_tool_output_for_stateless_runs(
|
||||
def submit_refund() -> str:
|
||||
return "submitted"
|
||||
|
||||
class RefundReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class RefundReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
@@ -608,7 +608,7 @@ async def test_handoff_resume_preserves_approved_tool_output_for_stateless_runs(
|
||||
|
||||
return _get()
|
||||
|
||||
class OrderReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class OrderReplayClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
@@ -907,7 +907,7 @@ async def test_handoff_async_termination_condition() -> None:
|
||||
async def test_handoff_terminates_without_request_info_when_latest_response_meets_condition() -> None:
|
||||
"""Termination triggered by the latest assistant response should not emit request_info."""
|
||||
|
||||
class FinalizingClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]):
|
||||
class FinalizingClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]):
|
||||
def __init__(self) -> None:
|
||||
ChatMiddlewareLayer.__init__(self)
|
||||
FunctionInvocationLayer.__init__(self)
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.0.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"azure-core>=1.30.0,<2",
|
||||
"httpx>=0.27.0,<0.29",
|
||||
]
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
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.0rc5",
|
||||
"agent-framework-core>=1.0.0rc4",
|
||||
"redis>=6.4.0,<7.2.1",
|
||||
"redisvl>=0.11.0,<0.16",
|
||||
"numpy>=2.2.6,<3"
|
||||
|
||||
@@ -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.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
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[all]==1.0.0rc5",
|
||||
"agent-framework-core[all]==1.0.0rc4",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -114,11 +114,10 @@ class RetryingAzureOpenAIChatClient(AzureOpenAIChatClient):
|
||||
|
||||
|
||||
class RateLimitRetryMiddleware(ChatMiddleware):
|
||||
"""Chat middleware that retries a single model-call pipeline on rate limit errors.
|
||||
"""Chat middleware that retries the full request pipeline on rate limit errors.
|
||||
|
||||
Register this middleware on an agent (or at the run level) to automatically
|
||||
retry any chat-model call that raises RateLimitError. In tool-loop scenarios,
|
||||
the middleware applies independently to each inner model call.
|
||||
retry any call_next() invocation that raises RateLimitError.
|
||||
"""
|
||||
|
||||
def __init__(self, *, max_attempts: int = RETRY_ATTEMPTS) -> None:
|
||||
@@ -155,9 +154,8 @@ async def rate_limit_retry_middleware(
|
||||
"""Function-based chat middleware that retries on rate limit errors.
|
||||
|
||||
Wrap call_next() with a tenacity @retry decorator so any RateLimitError
|
||||
raised during a single model call triggers an automatic retry with exponential
|
||||
back-off. In tool-loop scenarios, the middleware applies independently to
|
||||
each inner model call.
|
||||
raised during model inference triggers an automatic retry with exponential
|
||||
back-off.
|
||||
"""
|
||||
|
||||
@retry(
|
||||
|
||||
@@ -29,10 +29,7 @@ else:
|
||||
Custom Chat Client Implementation Example
|
||||
|
||||
This sample demonstrates implementing a custom chat client and optionally composing
|
||||
middleware, telemetry, and function invocation layers explicitly. The recommended
|
||||
layer order is `FunctionInvocationLayer -> ChatMiddlewareLayer -> ChatTelemetryLayer`
|
||||
so chat middleware runs within each tool-loop iteration while telemetry records
|
||||
per-call spans without middleware latency.
|
||||
middleware, telemetry, and function invocation layers explicitly.
|
||||
"""
|
||||
|
||||
|
||||
@@ -127,9 +124,9 @@ class EchoingChatClient(BaseChatClient[OptionsT]):
|
||||
|
||||
|
||||
class EchoingChatClientWithLayers( # type: ignore[misc]
|
||||
FunctionInvocationLayer[OptionsT],
|
||||
ChatMiddlewareLayer[OptionsT],
|
||||
ChatTelemetryLayer[OptionsT],
|
||||
FunctionInvocationLayer[OptionsT],
|
||||
EchoingChatClient,
|
||||
):
|
||||
"""Echoing chat client that explicitly composes middleware, telemetry, and function layers."""
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Evaluate an agent with local checks — no API keys needed.
|
||||
|
||||
Demonstrates the simplest evaluation workflow:
|
||||
1. Define checks using the @evaluator decorator
|
||||
2. Run evaluate_agent() which calls agent.run() under the covers
|
||||
3. Assert results in CI or inspect interactively
|
||||
|
||||
Usage:
|
||||
uv run python samples/02-agents/evaluation/evaluate_agent.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
LocalEvaluator,
|
||||
evaluate_agent,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
)
|
||||
|
||||
|
||||
# A custom check — parameter names determine what data you receive
|
||||
@evaluator
|
||||
def is_helpful(response: str) -> bool:
|
||||
"""Check the response isn't empty or a refusal."""
|
||||
refusals = ["i can't", "i'm not able", "i don't know"]
|
||||
return len(response) > 10 and not any(r in response.lower() for r in refusals)
|
||||
|
||||
|
||||
async def main():
|
||||
agent = Agent(
|
||||
model="gpt-4o-mini",
|
||||
instructions="You are a helpful weather assistant.",
|
||||
)
|
||||
|
||||
# Combine built-in and custom checks
|
||||
local = LocalEvaluator(
|
||||
keyword_check("weather"), # response must mention "weather"
|
||||
is_helpful, # custom check
|
||||
)
|
||||
|
||||
# evaluate_agent() calls agent.run() for each query, then evaluates
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=[
|
||||
"What's the weather like in Seattle?",
|
||||
"Will it rain in London tomorrow?",
|
||||
"What should I wear for 30°C weather?",
|
||||
],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"{r.provider}: {r.passed}/{r.total} passed")
|
||||
for item in r.items:
|
||||
print(f" [{item.status}] Q: {item.input_text[:50]} A: {item.output_text[:50]}...")
|
||||
for score in item.scores:
|
||||
print(f" {score.name}: {'✓' if score.passed else '✗'}")
|
||||
|
||||
# Use in CI: will raise AssertionError if any check fails
|
||||
# results[0].assert_passed()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Evaluate an agent with expected outputs and tool call checks.
|
||||
|
||||
Demonstrates ground-truth comparison and tool usage evaluation:
|
||||
1. Provide expected outputs alongside queries
|
||||
2. Use built-in tool_calls_present for tool verification
|
||||
3. Combine multiple evaluation criteria
|
||||
|
||||
Usage:
|
||||
uv run python samples/02-agents/evaluation/evaluate_with_expected.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
LocalEvaluator,
|
||||
evaluate_agent,
|
||||
evaluator,
|
||||
tool_calls_present,
|
||||
)
|
||||
|
||||
|
||||
@evaluator
|
||||
def response_matches_expected(response: str, expected_output: str) -> float:
|
||||
"""Score based on word overlap with expected output."""
|
||||
if not expected_output:
|
||||
return 1.0
|
||||
response_words = set(response.lower().split())
|
||||
expected_words = set(expected_output.lower().split())
|
||||
return len(response_words & expected_words) / max(len(expected_words), 1)
|
||||
|
||||
|
||||
async def main():
|
||||
agent = Agent(
|
||||
model="gpt-4o-mini",
|
||||
instructions="You are a math tutor. Answer concisely.",
|
||||
)
|
||||
|
||||
local = LocalEvaluator(
|
||||
response_matches_expected,
|
||||
tool_calls_present, # verifies expected tools were called
|
||||
)
|
||||
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What is 2 + 2?", "What is the square root of 144?"],
|
||||
expected_output=["4", "12"],
|
||||
expected_tool_calls=[
|
||||
[], # no tools expected for simple math
|
||||
[],
|
||||
],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"{r.provider}: {r.passed}/{r.total} passed")
|
||||
for item in r.items:
|
||||
print(f" [{item.status}] {item.input_text} → {item.output_text[:80]}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,37 +0,0 @@
|
||||
# Middleware samples
|
||||
|
||||
This folder contains focused middleware samples for `Agent`, chat clients, tools, sessions, and runtime context behavior.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| [`agent_and_run_level_middleware.py`](./agent_and_run_level_middleware.py) | Demonstrates combining agent-level and run-level middleware. |
|
||||
| [`chat_middleware.py`](./chat_middleware.py) | Shows class-based and function-based chat middleware that can observe, modify, and override model calls. |
|
||||
| [`class_based_middleware.py`](./class_based_middleware.py) | Shows class-based agent and function middleware. |
|
||||
| [`decorator_middleware.py`](./decorator_middleware.py) | Demonstrates middleware registration with decorators. |
|
||||
| [`exception_handling_with_middleware.py`](./exception_handling_with_middleware.py) | Shows how middleware can handle failures and recover cleanly. |
|
||||
| [`function_based_middleware.py`](./function_based_middleware.py) | Shows function-based agent and function middleware. |
|
||||
| [`middleware_termination.py`](./middleware_termination.py) | Demonstrates stopping a middleware pipeline early. |
|
||||
| [`override_result_with_middleware.py`](./override_result_with_middleware.py) | Shows how middleware can replace the normal result. |
|
||||
| [`runtime_context_delegation.py`](./runtime_context_delegation.py) | Demonstrates delegating work with runtime context data. |
|
||||
| [`session_behavior_middleware.py`](./session_behavior_middleware.py) | Shows how middleware interacts with session-backed runs. |
|
||||
| [`shared_state_middleware.py`](./shared_state_middleware.py) | Demonstrates sharing mutable state across middleware invocations. |
|
||||
| [`usage_tracking_middleware.py`](./usage_tracking_middleware.py) | Demonstrates one chat middleware function that tracks per-call usage in non-streaming and streaming tool-loop runs. |
|
||||
|
||||
## Running the usage tracking sample
|
||||
|
||||
The new usage tracking sample uses `OpenAIResponsesClient`, so set the usual OpenAI responses environment variables first:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
export OPENAI_RESPONSES_MODEL_ID="gpt-4.1-mini"
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
uv run samples/02-agents/middleware/usage_tracking_middleware.py
|
||||
```
|
||||
|
||||
The sample forces a tool call so you can see middleware output for each inner model call in both non-streaming and streaming modes.
|
||||
@@ -51,10 +51,10 @@ Agent Middleware Execution Order:
|
||||
- Run middleware wraps only the agent for that specific run
|
||||
- Each middleware can modify the context before AND after calling next()
|
||||
|
||||
Note: Function middleware executes during tool invocation, and chat middleware
|
||||
executes around each model call inside the agent execution, not in the outer
|
||||
agent-middleware chain shown above. They follow the same ordering principle:
|
||||
agent-level function/chat middleware runs before run-level function/chat middleware.
|
||||
Note: Function and chat middleware (e.g., ``function_logging_middleware``) execute
|
||||
during tool invocation *inside* the agent execution, not in the outer agent-middleware
|
||||
chain shown above. They follow the same ordering principle: agent-level function/chat
|
||||
middleware runs before run-level function/chat middleware.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
This sample demonstrates a single chat middleware that tracks per-model-call usage
|
||||
for both non-streaming and streaming tool-loop runs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from random import randint
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
ChatContext,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
ResponseStream,
|
||||
chat_middleware,
|
||||
tool,
|
||||
)
|
||||
from agent_framework.openai import OpenAIResponsesClient
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import Field
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
NON_STREAMING_CALL_COUNT = 0
|
||||
STREAMING_CALL_COUNT = 0
|
||||
|
||||
|
||||
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
|
||||
# see samples/02-agents/tools/function_tool_with_approval.py
|
||||
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
|
||||
@tool(approval_mode="never_require")
|
||||
def get_weather(
|
||||
location: Annotated[str, Field(description="The location to get the weather for.")],
|
||||
) -> str:
|
||||
"""Get the weather for a given location."""
|
||||
conditions = ["sunny", "cloudy", "rainy", "stormy"]
|
||||
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
|
||||
|
||||
|
||||
def _reset_usage_counters() -> None:
|
||||
"""Reset call counters between sample runs."""
|
||||
global NON_STREAMING_CALL_COUNT, STREAMING_CALL_COUNT
|
||||
NON_STREAMING_CALL_COUNT = 0
|
||||
STREAMING_CALL_COUNT = 0
|
||||
|
||||
|
||||
def _create_agent(
|
||||
) -> Agent:
|
||||
"""Create the shared agent used by both demonstrations."""
|
||||
return Agent(
|
||||
client=OpenAIResponsesClient(),
|
||||
instructions=(
|
||||
"You are a weather assistant. Always call the weather tool before answering weather questions, "
|
||||
"then summarize the tool result in one short paragraph."
|
||||
),
|
||||
tools=[get_weather],
|
||||
middleware=[print_usage],
|
||||
)
|
||||
|
||||
|
||||
@chat_middleware
|
||||
async def print_usage(
|
||||
context: ChatContext,
|
||||
call_next: Callable[[], Awaitable[None]],
|
||||
) -> None:
|
||||
"""Print usage for each inner model call in both non-streaming and streaming runs."""
|
||||
global NON_STREAMING_CALL_COUNT, STREAMING_CALL_COUNT
|
||||
|
||||
if context.stream:
|
||||
STREAMING_CALL_COUNT += 1
|
||||
call_number = STREAMING_CALL_COUNT
|
||||
usage_seen_in_updates = False
|
||||
|
||||
def capture_usage_update(update: ChatResponseUpdate) -> ChatResponseUpdate:
|
||||
nonlocal usage_seen_in_updates
|
||||
|
||||
for content in update.contents:
|
||||
if content.type == "usage":
|
||||
usage_seen_in_updates = True
|
||||
print(f"\n[Streaming model call #{call_number}] Usage update: {content.usage_details}")
|
||||
return update
|
||||
|
||||
def capture_final_usage(result: ChatResponse) -> ChatResponse:
|
||||
if not usage_seen_in_updates and result.usage_details:
|
||||
print(f"\n[Streaming model call #{call_number}] Final usage: {result.usage_details}")
|
||||
return result
|
||||
|
||||
context.stream_transform_hooks.append(capture_usage_update)
|
||||
context.stream_result_hooks.append(capture_final_usage)
|
||||
await call_next()
|
||||
return
|
||||
|
||||
NON_STREAMING_CALL_COUNT += 1
|
||||
call_number = NON_STREAMING_CALL_COUNT
|
||||
|
||||
await call_next()
|
||||
|
||||
response = context.result
|
||||
if isinstance(response, ChatResponse) and response.usage_details:
|
||||
print(f"[Non-streaming model call #{call_number}] Usage: {response.usage_details}")
|
||||
|
||||
|
||||
async def non_streaming_usage_example() -> None:
|
||||
"""Run the non-streaming usage tracking example."""
|
||||
_reset_usage_counters()
|
||||
print("\n=== Non-streaming per-call usage tracking ===")
|
||||
|
||||
# 1. Create an agent with middleware that prints usage after each inner model call.
|
||||
agent = _create_agent()
|
||||
|
||||
# 2. Run a weather question and require a tool call so the function loop performs multiple model calls.
|
||||
query = "What is the weather in Seattle, and should I bring an umbrella?"
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(
|
||||
query,
|
||||
options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
# 3. Print the final user-visible answer after the middleware already logged per-call usage.
|
||||
print(f"Assistant: {result.text}")
|
||||
|
||||
|
||||
async def streaming_usage_example() -> None:
|
||||
"""Run the streaming usage tracking example."""
|
||||
_reset_usage_counters()
|
||||
print("\n=== Streaming per-call usage tracking ===")
|
||||
|
||||
# 1. Create an agent with middleware that watches streaming usage for each inner model call.
|
||||
agent = _create_agent()
|
||||
|
||||
# 2. Start a streaming run and force tool usage so the function loop performs multiple model calls.
|
||||
query = "What is the weather in Portland, and should I bring a jacket?"
|
||||
print(f"User: {query}")
|
||||
print("Assistant: ", end="", flush=True)
|
||||
stream: ResponseStream = agent.run(
|
||||
query,
|
||||
stream=True,
|
||||
options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
# 3. Consume the stream normally while the middleware reports usage in the background.
|
||||
async for update in stream:
|
||||
if update.text:
|
||||
print(update.text, end="", flush=True)
|
||||
print()
|
||||
|
||||
# 4. Finalize the stream so you can inspect the final response if needed.
|
||||
final_response = await stream.get_final_response()
|
||||
print(f"Final assistant message: {final_response.text}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run both usage tracking demonstrations."""
|
||||
print("=== Usage Tracking Middleware Example ===")
|
||||
|
||||
await non_streaming_usage_example()
|
||||
await streaming_usage_example()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
"""
|
||||
Sample output:
|
||||
=== Usage Tracking Middleware Example ===
|
||||
|
||||
=== Non-streaming per-call usage tracking ===
|
||||
User: What is the weather in Seattle, and should I bring an umbrella?
|
||||
[Non-streaming model call #1] Usage: {'input_tokens': ..., 'output_tokens': ..., ...}
|
||||
[Non-streaming model call #2] Usage: {'input_tokens': ..., 'output_tokens': ..., ...}
|
||||
Assistant: Based on the weather in Seattle, ...
|
||||
|
||||
=== Streaming per-call usage tracking ===
|
||||
User: What is the weather in Portland, and should I bring a jacket?
|
||||
Assistant: Based on the weather in Portland, ...
|
||||
[Streaming model call #1] Usage update: {'input_tokens': ..., 'output_tokens': ..., ...}
|
||||
[Streaming model call #2] Usage update: {'input_tokens': ..., 'output_tokens': ..., ...}
|
||||
Final assistant message: Based on the weather in Portland, ...
|
||||
"""
|
||||
@@ -96,16 +96,10 @@ async def run_chat_client() -> None:
|
||||
stream: Whether to use streaming for the plugin
|
||||
|
||||
Remarks:
|
||||
By default, the built-in non-`Raw...Client` chat clients already compose
|
||||
the layers in this order:
|
||||
`FunctionInvocationLayer -> ChatMiddlewareLayer -> ChatTelemetryLayer -> Raw/Base client`.
|
||||
|
||||
When `FunctionInvocationLayer` is outside `ChatTelemetryLayer`,
|
||||
each call to the model is handled as a separate span.
|
||||
Keep `ChatMiddlewareLayer` outside telemetry
|
||||
so middleware latency does not skew those timings.
|
||||
By contrast, when telemetry is placed outside the function loop,
|
||||
a single span can cover one or more rounds of function calling.
|
||||
When function calling is outside the open telemetry loop
|
||||
each of the call to the model is handled as a seperate span,
|
||||
while when the open telemetry is put last, a single span
|
||||
is shown, which might include one or more rounds of function calling.
|
||||
|
||||
So for the scenario below, you should see the following:
|
||||
|
||||
|
||||
@@ -71,12 +71,10 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals
|
||||
stream: Whether to use streaming for the plugin
|
||||
|
||||
Remarks:
|
||||
When `FunctionInvocationLayer` is outside `ChatTelemetryLayer`,
|
||||
each call to the model is handled as a separate span.
|
||||
If `ChatMiddlewareLayer` is present, keep it outside telemetry
|
||||
so middleware latency does not skew those timings.
|
||||
By contrast, when telemetry is placed outside the function loop,
|
||||
a single span can cover one or more rounds of function calling.
|
||||
When function calling is outside the open telemetry loop
|
||||
each of the call to the model is handled as a separate span,
|
||||
while when the open telemetry is put last, a single span
|
||||
is shown, which might include one or more rounds of function calling.
|
||||
|
||||
So for the scenario below, you should see the following:
|
||||
|
||||
|
||||
@@ -37,17 +37,17 @@ The framework provides `Raw...Client` classes (e.g., `RawOpenAIChatClient`, `Raw
|
||||
|
||||
There is a defined ordering for applying layers that you should follow:
|
||||
|
||||
1. **FunctionInvocationLayer** - Handles the tool/function calling loop and should stay outermost
|
||||
2. **ChatMiddlewareLayer** - Wraps each model call in the loop and stays outside telemetry
|
||||
3. **ChatTelemetryLayer** - Must be inside the function calling loop so each model call gets its own telemetry span
|
||||
1. **ChatMiddlewareLayer** - Should be applied **first** because 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
|
||||
4. **Raw...Client** - The base implementation (e.g., `RawOpenAIChatClient`)
|
||||
|
||||
Example of correct layer composition:
|
||||
|
||||
```python
|
||||
class MyCustomClient(
|
||||
FunctionInvocationLayer[TOptions],
|
||||
ChatMiddlewareLayer[TOptions],
|
||||
FunctionInvocationLayer[TOptions],
|
||||
ChatTelemetryLayer[TOptions],
|
||||
RawOpenAIChatClient[TOptions], # or BaseChatClient for custom implementations
|
||||
Generic[TOptions],
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Evaluate a multi-agent workflow with per-agent breakdown.
|
||||
|
||||
Demonstrates workflow evaluation:
|
||||
1. Build a simple two-agent workflow
|
||||
2. Run evaluate_workflow() which runs the workflow and evaluates each agent
|
||||
3. Inspect per-agent results in sub_results
|
||||
|
||||
Usage:
|
||||
uv run python samples/03-workflows/evaluation/evaluate_workflow.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentExecutor,
|
||||
LocalEvaluator,
|
||||
WorkflowBuilder,
|
||||
evaluate_workflow,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
)
|
||||
|
||||
|
||||
@evaluator
|
||||
def is_nonempty(response: str) -> bool:
|
||||
"""Check the agent produced a non-trivial response."""
|
||||
return len(response.strip()) > 5
|
||||
|
||||
|
||||
async def main():
|
||||
# Build a simple planner → executor workflow
|
||||
planner = Agent(model="gpt-4o-mini", instructions="You plan trips. Output a bullet-point plan.")
|
||||
executor_agent = Agent(model="gpt-4o-mini", instructions="You execute travel plans. Book the items listed.")
|
||||
|
||||
builder = WorkflowBuilder()
|
||||
builder.add_executor(AgentExecutor("planner", planner))
|
||||
builder.add_executor(AgentExecutor("booker", executor_agent))
|
||||
builder.add_edge("planner", "booker")
|
||||
workflow = builder.build()
|
||||
|
||||
# Evaluate with per-agent breakdown
|
||||
local = LocalEvaluator(is_nonempty, keyword_check("plan", "trip"))
|
||||
|
||||
results = await evaluate_workflow(
|
||||
workflow=workflow,
|
||||
queries=["Plan a weekend trip to Paris"],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"{r.provider}: {r.passed}/{r.total} passed (overall)")
|
||||
for agent_name, sub in r.sub_results.items():
|
||||
print(f" {agent_name}: {sub.passed}/{sub.total}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
AZURE_AI_PROJECT_ENDPOINT="<your-project-endpoint>"
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME="<your-model-deployment>"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Foundry Evals Integration Samples
|
||||
|
||||
These samples demonstrate evaluating agent-framework agents using Azure AI Foundry's built-in evaluators.
|
||||
|
||||
## Available Evaluators
|
||||
|
||||
| Category | Evaluators |
|
||||
|----------|-----------|
|
||||
| **Agent behavior** | `intent_resolution`, `task_adherence`, `task_completion`, `task_navigation_efficiency` |
|
||||
| **Tool usage** | `tool_call_accuracy`, `tool_selection`, `tool_input_accuracy`, `tool_output_utilization`, `tool_call_success` |
|
||||
| **Quality** | `coherence`, `fluency`, `relevance`, `groundedness`, `response_completeness`, `similarity` |
|
||||
| **Safety** | `violence`, `sexual`, `self_harm`, `hate_unfairness` |
|
||||
|
||||
## Samples
|
||||
|
||||
### `evaluate_agent_sample.py` — Dataset Evaluation (Path 3)
|
||||
|
||||
The dev inner loop. Two patterns from simplest to most control:
|
||||
|
||||
1. **`evaluate_agent()`** — One call: runs agent → converts → evaluates
|
||||
2. **`evaluate_dataset()`** — Run agent yourself, convert with `AgentEvalConverter`, inspect/modify, then evaluate
|
||||
|
||||
```bash
|
||||
uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_agent_sample.py
|
||||
```
|
||||
|
||||
### `evaluate_traces_sample.py` — Trace & Response Evaluation (Path 1)
|
||||
|
||||
Evaluate what already happened — zero changes to agent code:
|
||||
|
||||
1. **`evaluate_responses()`** — Evaluate Responses API responses by ID
|
||||
2. **`evaluate_traces()`** — Evaluate from OTel traces in App Insights
|
||||
|
||||
```bash
|
||||
uv run samples/05-end-to-end/evaluation/foundry_evals/evaluate_traces_sample.py
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Create a `.env` file with configuration as in the `.env.example` file in this folder.
|
||||
|
||||
## Which sample should I start with?
|
||||
|
||||
- **"I want to test my agent during development"** → `evaluate_agent_sample.py`, Pattern 1
|
||||
- **"I want to evaluate past agent runs"** → `evaluate_traces_sample.py`
|
||||
- **"I want to inspect/modify eval data before submitting"** → `evaluate_agent_sample.py`, Pattern 2
|
||||
@@ -0,0 +1,195 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, AgentEvalConverter, ConversationSplit, evaluate_agent
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates evaluating an agent using Azure AI Foundry's built-in evaluators.
|
||||
|
||||
It shows three patterns:
|
||||
1. evaluate_agent(responses=...) — Evaluate a response you already have.
|
||||
2. evaluate_agent(queries=...) — Run the agent against test queries and evaluate in one call.
|
||||
3. FoundryEvals.evaluate() — Full control with direct evaluator access.
|
||||
|
||||
Prerequisites:
|
||||
- An Azure AI Foundry project with a deployed model
|
||||
- Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env
|
||||
|
||||
Required components:
|
||||
- An Agent with tools (the agent to evaluate)
|
||||
- A FoundryEvals instance (the evaluator)
|
||||
"""
|
||||
|
||||
|
||||
# Define a simple tool for the agent
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
weather_data = {
|
||||
"seattle": "62°F, cloudy with a chance of rain",
|
||||
"london": "55°F, overcast",
|
||||
"paris": "68°F, partly sunny",
|
||||
}
|
||||
return weather_data.get(location.lower(), f"Weather data not available for {location}")
|
||||
|
||||
|
||||
def get_flight_price(origin: str, destination: str) -> str:
|
||||
"""Get the price of a flight between two cities."""
|
||||
return f"Flights from {origin} to {destination}: $450 round-trip"
|
||||
|
||||
|
||||
async def main():
|
||||
# 1. Set up the Azure AI project client
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
# 2. Create an agent with tools
|
||||
agent = Agent(
|
||||
client=AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
),
|
||||
name="travel-assistant",
|
||||
instructions=(
|
||||
"You are a helpful travel assistant. Use your tools to answer questions about weather and flights."
|
||||
),
|
||||
tools=[get_weather, get_flight_price],
|
||||
)
|
||||
|
||||
# 3. Create the evaluator — provider config goes here, once
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 1: evaluate_agent(responses=...) — evaluate a response you already have
|
||||
# =========================================================================
|
||||
print("=" * 60)
|
||||
print("Pattern 1: evaluate_agent(responses=...) — evaluate existing response")
|
||||
print("=" * 60)
|
||||
|
||||
query = "How much does a flight from Seattle to Paris cost?"
|
||||
response = await agent.run(query)
|
||||
print(f"Agent said: {response.text[:100]}...")
|
||||
|
||||
# Pass agent= so tool definitions are extracted, queries= for the eval item context
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
responses=response,
|
||||
queries=[query],
|
||||
evaluators=evals.select(FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY),
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"Status: {r.status}")
|
||||
print(f"Results: {r.passed}/{r.total} passed")
|
||||
print(f"Portal: {r.report_url}")
|
||||
if r.all_passed:
|
||||
print("✓ All passed")
|
||||
else:
|
||||
print(f"✗ {r.failed} failed, {r.errored} errored")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 2a: evaluate_agent() — batch test queries
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 2a: evaluate_agent()")
|
||||
print("=" * 60)
|
||||
|
||||
# Calls agent.run() under the covers for each query, then evaluates
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=[
|
||||
"What's the weather like in Seattle?",
|
||||
"How much does a flight from Seattle to Paris cost?",
|
||||
"What should I pack for London?",
|
||||
],
|
||||
evaluators=evals, # uses smart defaults (auto-adds tool_call_accuracy)
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"Status: {r.status}")
|
||||
print(f"Results: {r.passed}/{r.total} passed")
|
||||
print(f"Portal: {r.report_url}")
|
||||
if r.all_passed:
|
||||
print("✓ All passed")
|
||||
else:
|
||||
print(f"✗ {r.failed} failed, {r.errored} errored")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 2b: evaluate_agent() — with conversation split override
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 2b: evaluate_agent() with conversation_split")
|
||||
print("=" * 60)
|
||||
|
||||
# conversation_split forces all evaluators to use the same split strategy.
|
||||
# FULL evaluates the entire conversation trajectory against the original query.
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=[
|
||||
"What's the weather like in Seattle?",
|
||||
"What should I pack for London?",
|
||||
],
|
||||
evaluators=evals,
|
||||
conversation_split=ConversationSplit.FULL, # overrides evaluator defaults
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"Status: {r.status}")
|
||||
print(f"Results: {r.passed}/{r.total} passed")
|
||||
print(f"Portal: {r.report_url}")
|
||||
if r.all_passed:
|
||||
print("✓ All passed")
|
||||
else:
|
||||
print(f"✗ {r.failed} failed, {r.errored} errored")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 3: FoundryEvals.evaluate() — manual control
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 3: FoundryEvals.evaluate() — manual control")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"What's the weather in Paris?",
|
||||
"Find me a flight from London to Seattle",
|
||||
]
|
||||
|
||||
items = []
|
||||
for q in queries:
|
||||
response = await agent.run(q)
|
||||
print(f"Query: {q}")
|
||||
print(f"Response: {response.text[:100]}...")
|
||||
|
||||
item = AgentEvalConverter.to_eval_item(query=q, response=response, agent=agent)
|
||||
items.append(item)
|
||||
|
||||
print(f" Has tools: {item.tools is not None}")
|
||||
if item.tools:
|
||||
print(f" Tools: {[t.name for t in item.tools]}")
|
||||
|
||||
# Submit directly to the evaluator
|
||||
tool_evals = evals.select(FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY)
|
||||
results = await tool_evals.evaluate(items, eval_name="Travel Assistant Eval")
|
||||
|
||||
print(f"\nStatus: {results.status}")
|
||||
print(f"Results: {results.passed}/{results.total} passed")
|
||||
print(f"Portal: {results.report_url}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+544
@@ -0,0 +1,544 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""
|
||||
Agent Evaluation — Complete Guide
|
||||
==================================
|
||||
|
||||
This sample shows every way to evaluate agents and workflows in
|
||||
Microsoft Agent Framework. Run the sections that match your needs.
|
||||
|
||||
┌──────────────────────────────────────┐
|
||||
│ Evaluation Options │
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Your own function (no setup) │
|
||||
│ 2. Built-in checks (no setup) │
|
||||
│ 3. Azure AI Foundry (cloud) │
|
||||
│ 4. Mix them all (recommended) │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
|
||||
Each evaluator plugs into the same two entry points:
|
||||
|
||||
evaluate_agent() — run agent + evaluate, or evaluate existing responses
|
||||
evaluate_workflow() — evaluate multi-agent workflows with per-agent breakdown
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
LocalEvaluator,
|
||||
Message,
|
||||
evaluate_agent,
|
||||
evaluate_workflow,
|
||||
evaluator,
|
||||
keyword_check,
|
||||
tool_called_check,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
from agent_framework_orchestrations import GroupChatBuilder, SequentialBuilder
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# ── Tools for our agents ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
return {"seattle": "62°F, cloudy", "london": "55°F, overcast", "paris": "68°F, sunny"}.get(
|
||||
location.lower(), f"No data for {location}"
|
||||
)
|
||||
|
||||
|
||||
def get_flight_price(origin: str, destination: str) -> str:
|
||||
"""Get the price of a flight between two cities."""
|
||||
return f"Flights from {origin} to {destination}: $450 round-trip"
|
||||
|
||||
|
||||
# ── Output helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def print_workflow_results(results):
|
||||
"""Print workflow eval results with clear provider → overall → per-agent hierarchy."""
|
||||
for r in results:
|
||||
status = "✓" if r.all_passed else "✗"
|
||||
print(f"\n {r.provider}:")
|
||||
print(f" {status} overall: {r.passed}/{r.total} passed")
|
||||
if r.report_url:
|
||||
print(f" Portal: {r.report_url}")
|
||||
for agent_name, sub in r.sub_results.items():
|
||||
agent_status = "✓" if sub.all_passed else "✗"
|
||||
print(f" {agent_status} {agent_name}: {sub.passed}/{sub.total}")
|
||||
if sub.report_url:
|
||||
print(f" Portal: {sub.report_url}")
|
||||
|
||||
|
||||
# ── Agent setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_agent(project_client, deployment):
|
||||
"""Create a travel assistant agent."""
|
||||
return Agent(
|
||||
client=AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
),
|
||||
name="travel-assistant",
|
||||
instructions="You are a helpful travel assistant. Use your tools to answer questions.",
|
||||
tools=[get_weather, get_flight_price],
|
||||
)
|
||||
|
||||
|
||||
def create_workflow(project_client, deployment):
|
||||
"""Create a researcher → planner sequential workflow."""
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
)
|
||||
researcher = Agent(
|
||||
client=client,
|
||||
name="researcher",
|
||||
instructions="You are a travel researcher. Use tools to gather weather and flight info.",
|
||||
tools=[get_weather, get_flight_price],
|
||||
default_options={"store": False},
|
||||
)
|
||||
planner = Agent(
|
||||
client=client,
|
||||
name="planner",
|
||||
instructions="You are a travel planner. Create a concise recommendation from the research.",
|
||||
default_options={"store": False},
|
||||
)
|
||||
return SequentialBuilder(participants=[researcher, planner]).build()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 1: Custom Function Evaluators
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Write a plain Python function. Name your parameters to get the data you need.
|
||||
# Return bool, float (≥0.5 = pass), or dict.
|
||||
#
|
||||
# Available parameters:
|
||||
# query, response, expected_output, conversation, tool_definitions, context
|
||||
#
|
||||
|
||||
# ── Simple check: just query + response ──────────────────────────────────────
|
||||
|
||||
|
||||
@evaluator
|
||||
def is_helpful(response: str) -> bool:
|
||||
"""Response should be more than a one-liner."""
|
||||
return len(response.split()) > 10
|
||||
|
||||
|
||||
@evaluator
|
||||
def no_apologies(query: str, response: str) -> bool:
|
||||
"""Agent shouldn't start with 'I'm sorry' or 'I apologize'."""
|
||||
lower = response.lower().strip()
|
||||
return not lower.startswith("i'm sorry") and not lower.startswith("i apologize")
|
||||
|
||||
|
||||
# ── Scored check: return a float ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@evaluator
|
||||
def relevance_keyword_overlap(query: str, response: str) -> float:
|
||||
"""Score based on how many query words appear in the response."""
|
||||
query_words = set(query.lower().split()) - {"the", "a", "in", "to", "is", "what", "how"}
|
||||
response_lower = response.lower()
|
||||
if not query_words:
|
||||
return 1.0
|
||||
return sum(1 for w in query_words if w in response_lower) / len(query_words)
|
||||
|
||||
|
||||
# ── Ground truth check: compare against expected output ──────────────────────
|
||||
|
||||
|
||||
@evaluator
|
||||
def mentions_expected_city(response: str, expected_output: str) -> bool:
|
||||
"""Response should mention the expected city."""
|
||||
return expected_output.lower() in response.lower()
|
||||
|
||||
|
||||
# ── Full context check: inspect conversation and tools ───────────────────────
|
||||
|
||||
|
||||
@evaluator
|
||||
def used_available_tools(conversation: list, tool_definitions: list) -> dict:
|
||||
"""Check that the agent actually called at least one of its tools."""
|
||||
available = {t.get("name", "") for t in (tool_definitions or [])}
|
||||
called = set()
|
||||
for msg in conversation:
|
||||
for tc in msg.get("tool_calls", []):
|
||||
name = tc.get("function", {}).get("name", "")
|
||||
if name:
|
||||
called.add(name)
|
||||
for ci in msg.get("content", []):
|
||||
if isinstance(ci, dict) and ci.get("type") == "tool_call":
|
||||
called.add(ci.get("name", ""))
|
||||
used = called & available
|
||||
return {
|
||||
"passed": len(used) > 0,
|
||||
"reason": f"Used {sorted(used)}" if used else f"No tools called (available: {sorted(available)})",
|
||||
}
|
||||
|
||||
|
||||
async def demo_evaluators(project_client, deployment):
|
||||
"""Evaluate an agent with custom function evaluators."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 1. Custom Function Evaluators")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
|
||||
local = LocalEvaluator(
|
||||
is_helpful,
|
||||
no_apologies,
|
||||
relevance_keyword_overlap,
|
||||
used_available_tools,
|
||||
)
|
||||
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?", "How much is a flight to Paris?"],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"\n {r.provider}: {r.passed}/{r.total} passed")
|
||||
for check, counts in r.per_evaluator.items():
|
||||
status = "✓" if counts["failed"] == 0 else "✗"
|
||||
print(f" {status} {check}: {counts['passed']}/{counts['passed'] + counts['failed']}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 2: Built-in Local Checks
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Pre-built checks for common patterns — no function needed.
|
||||
#
|
||||
|
||||
|
||||
async def demo_builtin_checks(project_client, deployment):
|
||||
"""Evaluate with built-in keyword and tool checks."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 2. Built-in Local Checks")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
|
||||
local = LocalEvaluator(
|
||||
keyword_check("weather", "seattle"), # response must contain these words
|
||||
tool_called_check("get_weather"), # agent must have called this tool
|
||||
)
|
||||
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
status = "✓" if r.all_passed else "✗"
|
||||
print(f"\n {status} {r.provider}: {r.passed}/{r.total} passed")
|
||||
for check, counts in r.per_evaluator.items():
|
||||
print(f" {check}: {counts}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 3: Azure AI Foundry Evaluators
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Cloud-powered AI quality assessment. Evaluates relevance, coherence,
|
||||
# task adherence, tool usage, and more.
|
||||
#
|
||||
|
||||
|
||||
async def demo_foundry_agent(project_client, deployment):
|
||||
"""Evaluate a single agent with Foundry."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 3a. Foundry — Single Agent")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# evaluate_agent: run + evaluate in one call
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?", "Find flights from London to Paris"],
|
||||
evaluators=evals,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"\n {r.provider}: {r.passed}/{r.total} passed")
|
||||
print(f" Portal: {r.report_url}")
|
||||
|
||||
|
||||
async def demo_foundry_response(project_client, deployment):
|
||||
"""Evaluate a response you already have."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 3b. Foundry — Existing Response")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
|
||||
# Run the agent yourself
|
||||
response = await agent.run([Message("user", ["What's the weather in Seattle?"])])
|
||||
print(f" Agent said: {response.text[:80]}...")
|
||||
|
||||
# Then evaluate the response (without re-running the agent)
|
||||
quality_evals = FoundryEvals(
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
|
||||
)
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
responses=response,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=quality_evals,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"\n {r.provider}: {r.passed}/{r.total} passed")
|
||||
|
||||
|
||||
async def demo_foundry_workflow(project_client, deployment):
|
||||
"""Evaluate a multi-agent workflow with per-agent breakdown."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 3c. Foundry — Multi-Agent Workflow")
|
||||
print("═" * 60)
|
||||
|
||||
workflow = create_workflow(project_client, deployment)
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# Run + evaluate with multiple queries
|
||||
results = await evaluate_workflow(
|
||||
workflow=workflow,
|
||||
queries=["Plan a trip from Seattle to Paris"],
|
||||
evaluators=evals,
|
||||
)
|
||||
|
||||
print_workflow_results(results)
|
||||
|
||||
|
||||
async def demo_foundry_select(project_client, deployment):
|
||||
"""Choose specific Foundry evaluators."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 3d. Foundry — Selecting Evaluators")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
|
||||
# Pick exactly which evaluators to run
|
||||
evals = FoundryEvals(
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
evaluators=[
|
||||
FoundryEvals.RELEVANCE,
|
||||
FoundryEvals.TASK_ADHERENCE,
|
||||
FoundryEvals.TOOL_CALL_ACCURACY,
|
||||
],
|
||||
)
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=evals,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"\n {r.provider}: {r.passed}/{r.total} passed")
|
||||
for ev_name, counts in r.per_evaluator.items():
|
||||
print(f" {ev_name}: {counts}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 4: Mix Everything Together
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Pass a list of evaluators — local functions, built-in checks, and Foundry
|
||||
# all run together. You get one EvalResults per provider.
|
||||
#
|
||||
|
||||
|
||||
async def demo_mixed(project_client, deployment):
|
||||
"""Combine custom functions, built-in checks, and Foundry in one call."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 4. Mixed Evaluation (recommended)")
|
||||
print("═" * 60)
|
||||
|
||||
agent = create_agent(project_client, deployment)
|
||||
|
||||
# Local: custom functions + built-in checks
|
||||
local = LocalEvaluator(
|
||||
is_helpful,
|
||||
no_apologies,
|
||||
keyword_check("weather"),
|
||||
tool_called_check("get_weather"),
|
||||
)
|
||||
|
||||
# Cloud: Foundry AI quality assessment
|
||||
foundry = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# One call, multiple providers
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=[
|
||||
"What's the weather in Seattle?",
|
||||
"How much is a flight from London to Paris?",
|
||||
],
|
||||
evaluators=[local, foundry],
|
||||
)
|
||||
|
||||
print()
|
||||
for r in results:
|
||||
status = "✓" if r.all_passed else "✗"
|
||||
print(f" {status} {r.provider}: {r.passed}/{r.total} passed")
|
||||
for ev_name, counts in r.per_evaluator.items():
|
||||
p, f = counts["passed"], counts["failed"]
|
||||
print(f" {ev_name}: {p}/{p + f}")
|
||||
if r.report_url:
|
||||
print(f" Portal: {r.report_url}")
|
||||
|
||||
# CI assertion — fails the test if anything didn't pass
|
||||
for r in results:
|
||||
r.assert_passed()
|
||||
print("\n ✓ All evaluations passed!")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 5: Workflow + Mixed Evaluation
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def demo_workflow_mixed(project_client, deployment):
|
||||
"""Evaluate a workflow with both local and Foundry evaluators."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 5. Workflow + Mixed Evaluation")
|
||||
print("═" * 60)
|
||||
|
||||
workflow = create_workflow(project_client, deployment)
|
||||
|
||||
local = LocalEvaluator(is_helpful, no_apologies)
|
||||
foundry = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
results = await evaluate_workflow(
|
||||
workflow=workflow,
|
||||
queries=["Plan a trip from Seattle to Paris"],
|
||||
evaluators=[local, foundry],
|
||||
)
|
||||
|
||||
print_workflow_results(results)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Section 6: Iterative Workflows (agents run multiple times)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# When an agent runs multiple times in a single workflow execution (e.g., in
|
||||
# a group chat or feedback loop), each invocation becomes a separate eval item.
|
||||
# Results are grouped by agent, so you see e.g. "writer: 3/3 passed".
|
||||
#
|
||||
|
||||
|
||||
def create_iterative_workflow(project_client, deployment):
|
||||
"""Create a group chat where a writer and reviewer iterate.
|
||||
|
||||
The writer drafts a response, the reviewer critiques it, and the
|
||||
writer revises — running 2 rounds so each agent is invoked twice.
|
||||
"""
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
)
|
||||
writer = Agent(
|
||||
client=client,
|
||||
name="writer",
|
||||
instructions=(
|
||||
"You are a travel copywriter. Write or revise a short, "
|
||||
"compelling travel description based on the conversation."
|
||||
),
|
||||
default_options={"store": False},
|
||||
)
|
||||
reviewer = Agent(
|
||||
client=client,
|
||||
name="reviewer",
|
||||
instructions=("You are an editor. Critique the writer's draft and suggest specific improvements. Be concise."),
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
# Group chat with round-robin selection: writer → reviewer → writer → reviewer
|
||||
# Each agent runs twice per query.
|
||||
def round_robin(state):
|
||||
names = list(state.participants.keys())
|
||||
return names[state.current_round % len(names)]
|
||||
|
||||
return GroupChatBuilder(
|
||||
participants=[writer, reviewer],
|
||||
termination_condition=lambda conversation: len(conversation) >= 5,
|
||||
selection_func=round_robin,
|
||||
).build()
|
||||
|
||||
|
||||
async def demo_iterative_workflow(project_client, deployment):
|
||||
"""Evaluate a workflow where agents run multiple times."""
|
||||
print()
|
||||
print("═" * 60)
|
||||
print(" 6. Iterative Workflow (multi-run agents)")
|
||||
print("═" * 60)
|
||||
|
||||
workflow = create_iterative_workflow(project_client, deployment)
|
||||
|
||||
local = LocalEvaluator(is_helpful, no_apologies)
|
||||
|
||||
results = await evaluate_workflow(
|
||||
workflow=workflow,
|
||||
queries=["Write a travel description for Kyoto in autumn"],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
print_workflow_results(results)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# Run it
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
async def main():
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
# Run each section — comment out what you don't need
|
||||
# await demo_evaluators(project_client, deployment)
|
||||
# await demo_builtin_checks(project_client, deployment)
|
||||
# await demo_foundry_agent(project_client, deployment)
|
||||
# await demo_foundry_response(project_client, deployment)
|
||||
# await demo_foundry_workflow(project_client, deployment)
|
||||
# await demo_foundry_select(project_client, deployment)
|
||||
# await demo_mixed(project_client, deployment)
|
||||
await demo_workflow_mixed(project_client, deployment)
|
||||
await demo_iterative_workflow(project_client, deployment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,166 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
LocalEvaluator,
|
||||
evaluate_agent,
|
||||
keyword_check,
|
||||
tool_called_check,
|
||||
)
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates mixing local and cloud evaluation providers.
|
||||
|
||||
It shows three patterns:
|
||||
1. Local-only: Fast, API-free checks for inner-loop development.
|
||||
2. Cloud-only: Full Foundry evaluators for comprehensive quality assessment.
|
||||
3. Mixed: Local + Foundry evaluators in a single evaluate_agent() call.
|
||||
|
||||
Mixing lets you get instant local feedback (keyword presence, tool usage)
|
||||
alongside deeper cloud-based quality evaluation (relevance, coherence)
|
||||
in one call.
|
||||
|
||||
Prerequisites:
|
||||
- An Azure AI Foundry project with a deployed model
|
||||
- Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env
|
||||
"""
|
||||
|
||||
|
||||
# Define a simple tool for the agent
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
weather_data = {
|
||||
"seattle": "62°F, cloudy with a chance of rain",
|
||||
"london": "55°F, overcast",
|
||||
"paris": "68°F, partly sunny",
|
||||
}
|
||||
return weather_data.get(location.lower(), f"Weather data not available for {location}")
|
||||
|
||||
|
||||
async def main():
|
||||
# 1. Set up the Azure AI project client
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
# 2. Create an agent with a tool
|
||||
agent = Agent(
|
||||
client=AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
),
|
||||
name="weather-assistant",
|
||||
instructions="You are a helpful weather assistant. Use the get_weather tool to answer questions.",
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 1: Local evaluation only (no API calls, instant results)
|
||||
# =========================================================================
|
||||
print("=" * 60)
|
||||
print("Pattern 1: Local evaluation only")
|
||||
print("=" * 60)
|
||||
|
||||
local = LocalEvaluator(
|
||||
keyword_check("weather", "seattle"),
|
||||
tool_called_check("get_weather"),
|
||||
)
|
||||
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=local,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"Status: {r.status}")
|
||||
print(f"Results: {r.passed}/{r.total} passed")
|
||||
for check_name, counts in r.per_evaluator.items():
|
||||
print(f" {check_name}: {counts['passed']} passed, {counts['failed']} failed")
|
||||
if r.all_passed:
|
||||
print("✓ All local checks passed!")
|
||||
else:
|
||||
print(f"✗ Failures: {r.error}")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 2: Foundry evaluation only (cloud-based quality assessment)
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 2: Foundry evaluation only")
|
||||
print("=" * 60)
|
||||
|
||||
foundry = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=["What's the weather in Seattle?"],
|
||||
evaluators=foundry,
|
||||
)
|
||||
|
||||
for r in results:
|
||||
print(f"Status: {r.status}")
|
||||
print(f"Results: {r.passed}/{r.total} passed")
|
||||
print(f"Portal: {r.report_url}")
|
||||
if r.all_passed:
|
||||
print("✓ All passed")
|
||||
else:
|
||||
print(f"✗ {r.failed} failed, {r.errored} errored")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 3: Mixed — local + Foundry in one call
|
||||
# =========================================================================
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 3: Mixed local + Foundry evaluation")
|
||||
print("=" * 60)
|
||||
|
||||
# Local checks: fast smoke tests
|
||||
local = LocalEvaluator(
|
||||
keyword_check("weather"),
|
||||
tool_called_check("get_weather"),
|
||||
)
|
||||
|
||||
# Foundry: deep quality assessment
|
||||
foundry = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# Pass both as a list — returns one EvalResults per provider
|
||||
results = await evaluate_agent(
|
||||
agent=agent,
|
||||
queries=[
|
||||
"What's the weather in Seattle?",
|
||||
"Tell me the weather in London",
|
||||
],
|
||||
evaluators=[local, foundry],
|
||||
)
|
||||
|
||||
for r in results:
|
||||
status = "✓" if r.all_passed else "✗"
|
||||
print(f" {status} {r.provider}: {r.passed}/{r.total} passed")
|
||||
for check_name, counts in r.per_evaluator.items():
|
||||
print(f" {check_name}: {counts['passed']}/{counts['passed'] + counts['failed']}")
|
||||
if r.report_url:
|
||||
print(f" Portal: {r.report_url}")
|
||||
|
||||
if all(r.all_passed for r in results):
|
||||
print("✓ All checks passed (local + Foundry)!")
|
||||
else:
|
||||
failed = [r.provider for r in results if not r.all_passed]
|
||||
print(f"✗ Failed providers: {', '.join(failed)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,191 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import ConversationSplit, EvalItem
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates how conversation split strategies affect evaluation.
|
||||
|
||||
The same multi-turn conversation can be split different ways, each evaluating
|
||||
a different aspect of agent behavior:
|
||||
|
||||
1. LAST_TURN (default) — "Was the last response good given context?"
|
||||
2. FULL — "Did the whole conversation serve the original request?"
|
||||
3. per_turn_items — "Was each individual response appropriate?"
|
||||
|
||||
Prerequisites:
|
||||
- An Azure AI Foundry project with a deployed model
|
||||
- Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env
|
||||
"""
|
||||
|
||||
# A multi-turn conversation with tool calls that we'll evaluate three ways.
|
||||
CONVERSATION = [
|
||||
# Turn 1: user asks about weather → agent calls tool → responds
|
||||
{"role": "user", "content": "What's the weather in Seattle?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_call", "tool_call_id": "c1", "name": "get_weather", "arguments": {"location": "seattle"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c1",
|
||||
"content": [{"type": "tool_result", "tool_result": "62°F, cloudy with a chance of rain"}],
|
||||
},
|
||||
{"role": "assistant", "content": "Seattle is 62°F, cloudy with a chance of rain."},
|
||||
# Turn 2: user asks about Paris → agent calls tool → responds
|
||||
{"role": "user", "content": "And Paris?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_call", "tool_call_id": "c2", "name": "get_weather", "arguments": {"location": "paris"}}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "c2",
|
||||
"content": [{"type": "tool_result", "tool_result": "68°F, partly sunny"}],
|
||||
},
|
||||
{"role": "assistant", "content": "Paris is 68°F, partly sunny."},
|
||||
# Turn 3: user asks for comparison → agent synthesizes without tool
|
||||
{"role": "user", "content": "Can you compare them?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "Seattle is cooler at 62°F with rain likely, while Paris is warmer at 68°F and partly sunny. Paris is the better choice for outdoor activities.",
|
||||
},
|
||||
]
|
||||
|
||||
TOOL_DEFINITIONS = [
|
||||
{
|
||||
"name": "get_weather",
|
||||
"description": "Get the current weather for a location.",
|
||||
"parameters": {"type": "object", "properties": {"location": {"type": "string"}}},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def print_split(item: EvalItem, split: ConversationSplit = ConversationSplit.LAST_TURN):
|
||||
"""Print the query/response split for an EvalItem."""
|
||||
d = item.to_eval_data(split=split)
|
||||
print(f" query_messages ({len(d['query_messages'])}):")
|
||||
for m in d["query_messages"]:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = content[0].get("type", str(content[0]))
|
||||
print(f" {m['role']}: {str(content)[:70]}")
|
||||
print(f" response_messages ({len(d['response_messages'])}):")
|
||||
for m in d["response_messages"]:
|
||||
content = m.get("content", "")
|
||||
if isinstance(content, list):
|
||||
content = content[0].get("type", str(content[0]))
|
||||
print(f" {m['role']}: {str(content)[:70]}")
|
||||
|
||||
|
||||
async def main():
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
# =========================================================================
|
||||
# Strategy 1: LAST_TURN (default)
|
||||
# "Given all context, was the last response good?"
|
||||
# =========================================================================
|
||||
print("=" * 70)
|
||||
print("Strategy 1: LAST_TURN — evaluate the final response")
|
||||
print("=" * 70)
|
||||
|
||||
item = EvalItem(
|
||||
query="Can you compare them?",
|
||||
response="Seattle is cooler at 62°F with rain likely, while Paris is warmer at 68°F and partly sunny. Paris is the better choice for outdoor activities.",
|
||||
conversation=CONVERSATION,
|
||||
tool_definitions=TOOL_DEFINITIONS,
|
||||
)
|
||||
|
||||
print_split(item, ConversationSplit.LAST_TURN)
|
||||
|
||||
results = await FoundryEvals(
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
|
||||
# conversation_split defaults to LAST_TURN
|
||||
).evaluate([item], eval_name="Split Strategy: LAST_TURN")
|
||||
|
||||
print(f"\n Result: {results.passed}/{results.total} passed")
|
||||
print(f" Portal: {results.report_url}")
|
||||
for ir in results.items:
|
||||
for s in ir.scores:
|
||||
print(f" {'✓' if s.passed else '✗'} {s.name}: {s.score}")
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Strategy 2: FULL
|
||||
# "Given the original request, did the whole conversation serve the user?"
|
||||
# =========================================================================
|
||||
print("=" * 70)
|
||||
print("Strategy 2: FULL — evaluate the entire conversation trajectory")
|
||||
print("=" * 70)
|
||||
|
||||
print_split(item, ConversationSplit.FULL)
|
||||
|
||||
results = await FoundryEvals(
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
|
||||
conversation_split=ConversationSplit.FULL,
|
||||
).evaluate([item], eval_name="Split Strategy: FULL")
|
||||
|
||||
print(f"\n Result: {results.passed}/{results.total} passed")
|
||||
print(f" Portal: {results.report_url}")
|
||||
for ir in results.items:
|
||||
for s in ir.scores:
|
||||
print(f" {'✓' if s.passed else '✗'} {s.name}: {s.score}")
|
||||
print()
|
||||
|
||||
# =========================================================================
|
||||
# Strategy 3: per_turn_items
|
||||
# "Was each individual response appropriate at that point?"
|
||||
# =========================================================================
|
||||
print("=" * 70)
|
||||
print("Strategy 3: per_turn_items — evaluate each turn independently")
|
||||
print("=" * 70)
|
||||
|
||||
items = EvalItem.per_turn_items(
|
||||
CONVERSATION,
|
||||
tool_definitions=TOOL_DEFINITIONS,
|
||||
)
|
||||
print(f" Split into {len(items)} items from {len(CONVERSATION)} messages:\n")
|
||||
for i, it in enumerate(items):
|
||||
print(f" Turn {i + 1}: query={it.query!r}, response={it.response[:60]!r}...")
|
||||
print()
|
||||
|
||||
results = await FoundryEvals(
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
|
||||
).evaluate(items, eval_name="Split Strategy: Per-Turn")
|
||||
|
||||
print(f"\n Result: {results.passed}/{results.total} passed ({len(items)} items × 2 evaluators)")
|
||||
print(f" Portal: {results.report_url}")
|
||||
for ir in results.items:
|
||||
for s in ir.scores:
|
||||
print(f" {'✓' if s.passed else '✗'} {s.name}: {s.score}")
|
||||
print()
|
||||
|
||||
print("=" * 70)
|
||||
print("All strategies complete. Compare results in the Foundry portal.")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework_azure_ai import FoundryEvals, evaluate_traces
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates evaluating agent responses that already exist in Foundry.
|
||||
|
||||
It shows two patterns:
|
||||
1. evaluate_traces(response_ids=...) — Evaluate specific Responses API responses by ID.
|
||||
2. evaluate_traces(agent_id=...) — Evaluate agent behavior from OTel traces in App Insights.
|
||||
|
||||
These are the "zero-code-change" evaluation paths — the agent has already run,
|
||||
and you're evaluating what happened after the fact.
|
||||
|
||||
Prerequisites:
|
||||
- An Azure AI Foundry project with a deployed model
|
||||
- Response IDs from prior agent runs (for Pattern 1)
|
||||
- OTel traces exported to App Insights (for Pattern 2)
|
||||
- Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env
|
||||
"""
|
||||
|
||||
|
||||
async def main():
|
||||
# 1. Set up the Azure AI project client
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 1: evaluate_traces(response_ids=...) — By response ID
|
||||
# =========================================================================
|
||||
# If your agent uses the Responses API (e.g., AzureOpenAIResponsesClient),
|
||||
# each run produces a response_id. Pass those IDs to evaluate_traces()
|
||||
# and Foundry retrieves the full conversation for evaluation.
|
||||
print("=" * 60)
|
||||
print("Pattern 1: evaluate_traces(response_ids=...)")
|
||||
print("=" * 60)
|
||||
|
||||
# Replace these with actual response IDs from your agent runs
|
||||
response_ids = [
|
||||
"resp_abc123",
|
||||
"resp_def456",
|
||||
]
|
||||
|
||||
results = await evaluate_traces(
|
||||
response_ids=response_ids,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.GROUNDEDNESS, FoundryEvals.TOOL_CALL_ACCURACY],
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
)
|
||||
|
||||
print(f"Status: {results.status}")
|
||||
print(f"Results: {results.result_counts}")
|
||||
print(f"Portal: {results.report_url}")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 2: evaluate_traces(agent_id=...) — From App Insights
|
||||
# =========================================================================
|
||||
# If your agent emits OTel traces to App Insights (via configure_otel_providers),
|
||||
# you can evaluate recent activity without specifying individual response IDs.
|
||||
#
|
||||
# NOTE: Requires OTel traces exported to the App Insights instance connected
|
||||
# to your Foundry project. The exact trace-based data source API is subject
|
||||
# to change as Foundry evolves.
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 2: evaluate_traces(agent_id=...)")
|
||||
print("=" * 60)
|
||||
|
||||
# Evaluate by response IDs (uses response-based data source internally)
|
||||
results = await evaluate_traces(
|
||||
response_ids=response_ids,
|
||||
evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE],
|
||||
project_client=project_client,
|
||||
model_deployment=deployment,
|
||||
)
|
||||
|
||||
print(f"Status: {results.status}")
|
||||
print(f"Portal: {results.report_url}")
|
||||
|
||||
# Evaluate by agent ID + time window (when trace-based API is available)
|
||||
# results = await evaluate_traces(
|
||||
# agent_id="travel-bot",
|
||||
# evaluators=[FoundryEvals.INTENT_RESOLUTION, FoundryEvals.TASK_ADHERENCE],
|
||||
# project_client=project_client,
|
||||
# model_deployment=deployment,
|
||||
# lookback_hours=24,
|
||||
# )
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
"""
|
||||
Sample output (with actual Azure AI Foundry project and valid response IDs):
|
||||
|
||||
============================================================
|
||||
Pattern 1: evaluate_traces(response_ids=...)
|
||||
============================================================
|
||||
Status: completed
|
||||
Results: {'passed': 2, 'failed': 0, 'errored': 0}
|
||||
Portal: https://ai.azure.com/...
|
||||
|
||||
============================================================
|
||||
Pattern 2: evaluate_traces(agent_id=...)
|
||||
============================================================
|
||||
Status: completed
|
||||
Portal: https://ai.azure.com/...
|
||||
"""
|
||||
@@ -0,0 +1,182 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, evaluate_workflow
|
||||
from agent_framework.azure import AzureOpenAIResponsesClient
|
||||
from agent_framework_azure_ai import FoundryEvals
|
||||
from agent_framework_orchestrations import SequentialBuilder
|
||||
from azure.ai.projects.aio import AIProjectClient
|
||||
from azure.identity import DefaultAzureCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
This sample demonstrates evaluating a multi-agent workflow using Azure AI Foundry evaluators.
|
||||
|
||||
It shows two patterns:
|
||||
1. Post-hoc: Run the workflow, then evaluate the result you already have.
|
||||
2. Run + evaluate: Pass queries and let evaluate_workflow() run the workflow for you.
|
||||
|
||||
Both patterns return a list of results (one per provider), each with a per-agent
|
||||
breakdown in sub_results so you can identify which agent is underperforming.
|
||||
|
||||
Prerequisites:
|
||||
- An Azure AI Foundry project with a deployed model
|
||||
- Set AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_MODEL_DEPLOYMENT_NAME in .env
|
||||
"""
|
||||
|
||||
|
||||
# Simple tools for the agents
|
||||
def get_weather(location: str) -> str:
|
||||
"""Get the current weather for a location."""
|
||||
weather_data = {
|
||||
"seattle": "62°F, cloudy with a chance of rain",
|
||||
"london": "55°F, overcast",
|
||||
"paris": "68°F, partly sunny",
|
||||
}
|
||||
return weather_data.get(location.lower(), f"Weather data not available for {location}")
|
||||
|
||||
|
||||
def get_flight_price(origin: str, destination: str) -> str:
|
||||
"""Get the price of a flight between two cities."""
|
||||
return f"Flights from {origin} to {destination}: $450 round-trip"
|
||||
|
||||
|
||||
async def main():
|
||||
# 1. Set up the Azure AI project client
|
||||
project_client = AIProjectClient(
|
||||
endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"],
|
||||
credential=DefaultAzureCredential(),
|
||||
)
|
||||
|
||||
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME", "gpt-4o")
|
||||
|
||||
client = AzureOpenAIResponsesClient(
|
||||
project_client=project_client,
|
||||
deployment_name=deployment,
|
||||
)
|
||||
|
||||
# 2. Create agents for a sequential workflow
|
||||
# Use store=False so agents don't chain conversation state via previous_response_id.
|
||||
# This allows the workflow to be run multiple times without stale state issues.
|
||||
researcher = Agent(
|
||||
client=client,
|
||||
name="researcher",
|
||||
instructions=(
|
||||
"You are a travel researcher. Use your tools to gather weather "
|
||||
"and flight information for the destination the user asks about."
|
||||
),
|
||||
tools=[get_weather, get_flight_price],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
planner = Agent(
|
||||
client=client,
|
||||
name="planner",
|
||||
instructions=(
|
||||
"You are a travel planner. Based on the research provided, "
|
||||
"create a concise travel recommendation with packing tips."
|
||||
),
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
# 3. Build a sequential workflow: researcher → planner
|
||||
workflow = SequentialBuilder(participants=[researcher, planner]).build()
|
||||
|
||||
# 4. Create the evaluator — provider config goes here, once
|
||||
evals = FoundryEvals(project_client=project_client, model_deployment=deployment)
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 1: Post-hoc — evaluate a workflow run you already did
|
||||
# =========================================================================
|
||||
print("=" * 60)
|
||||
print("Pattern 1: Post-hoc workflow evaluation")
|
||||
print("=" * 60)
|
||||
|
||||
result = await workflow.run("Plan a trip from Seattle to Paris")
|
||||
|
||||
eval_results = await evaluate_workflow(
|
||||
workflow=workflow,
|
||||
workflow_result=result,
|
||||
evaluators=evals,
|
||||
)
|
||||
|
||||
for r in eval_results:
|
||||
print(f"\nOverall: {r.status}")
|
||||
print(f" Passed: {r.passed}/{r.total}")
|
||||
print(f" Portal: {r.report_url}")
|
||||
|
||||
print("\nPer-agent breakdown:")
|
||||
for agent_name, agent_eval in r.sub_results.items():
|
||||
print(f" {agent_name}: {agent_eval.passed}/{agent_eval.total} passed")
|
||||
if agent_eval.report_url:
|
||||
print(f" Portal: {agent_eval.report_url}")
|
||||
|
||||
# =========================================================================
|
||||
# Pattern 2: Run + evaluate with multiple queries
|
||||
# =========================================================================
|
||||
# Build a fresh workflow to avoid stale session state from Pattern 1.
|
||||
# The Responses API tracks previous_response_id per session, so reusing
|
||||
# a workflow after a run would reference stale tool calls.
|
||||
workflow2 = SequentialBuilder(participants=[researcher, planner]).build()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Pattern 2: Run + evaluate with multiple queries")
|
||||
print("=" * 60)
|
||||
|
||||
eval_results = await evaluate_workflow(
|
||||
workflow=workflow2,
|
||||
queries=[
|
||||
"Plan a trip from London to Tokyo",
|
||||
"Plan a trip from New York to Rome",
|
||||
],
|
||||
evaluators=evals.select(FoundryEvals.RELEVANCE, FoundryEvals.TASK_ADHERENCE),
|
||||
)
|
||||
|
||||
for r in eval_results:
|
||||
print(f"\nOverall: {r.status}")
|
||||
print(f" Passed: {r.passed}/{r.total}")
|
||||
if r.report_url:
|
||||
print(f" Portal: {r.report_url}")
|
||||
|
||||
print("\nPer-agent breakdown:")
|
||||
for agent_name, agent_eval in r.sub_results.items():
|
||||
print(f" {agent_name}: {agent_eval.passed}/{agent_eval.total} passed")
|
||||
if agent_eval.report_url:
|
||||
print(f" Portal: {agent_eval.report_url}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
"""
|
||||
Sample output (with actual Azure AI Foundry project):
|
||||
|
||||
============================================================
|
||||
Pattern 1: Post-hoc workflow evaluation
|
||||
============================================================
|
||||
|
||||
Overall: completed
|
||||
Passed: 2/2
|
||||
Portal: https://ai.azure.com/...
|
||||
|
||||
Per-agent breakdown:
|
||||
researcher: 1/1 passed
|
||||
planner: 1/1 passed
|
||||
|
||||
============================================================
|
||||
Pattern 2: Run + evaluate with multiple queries
|
||||
============================================================
|
||||
|
||||
Overall: completed
|
||||
Passed: 4/4
|
||||
|
||||
Per-agent breakdown:
|
||||
researcher: 2/2 passed
|
||||
planner: 2/2 passed
|
||||
"""
|
||||
Generated
+24
-24
@@ -91,7 +91,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework"
|
||||
version = "1.0.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -140,7 +140,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -155,7 +155,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ag-ui"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/ag-ui" }
|
||||
dependencies = [
|
||||
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -183,7 +183,7 @@ provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-anthropic"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/anthropic" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -198,7 +198,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai"
|
||||
version = "1.0.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
source = { editable = "packages/azure-ai" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -217,7 +217,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-ai-search"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/azure-ai-search" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -232,7 +232,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azure-cosmos"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/azure-cosmos" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -247,7 +247,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-azurefunctions"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/azurefunctions" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -269,7 +269,7 @@ dev = []
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-bedrock"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/bedrock" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -286,7 +286,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/chatkit" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -301,7 +301,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-claude"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/claude" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -316,7 +316,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-copilotstudio"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/copilotstudio" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -331,7 +331,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-core"
|
||||
version = "1.0.0rc5"
|
||||
version = "1.0.0rc4"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "azure-ai-projects", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -411,7 +411,7 @@ provides-extras = ["all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-declarative"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/declarative" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -436,7 +436,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-devui"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/devui" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -470,7 +470,7 @@ provides-extras = ["dev", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-durabletask"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/durabletask" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -497,7 +497,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260305" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry-local"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/foundry_local" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -512,7 +512,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-github-copilot"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/github_copilot" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -527,7 +527,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-lab"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/lab" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -606,7 +606,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-mem0"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/mem0" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -621,7 +621,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-ollama"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/ollama" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -636,7 +636,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-orchestrations"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/orchestrations" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -647,7 +647,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-purview"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/purview" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -664,7 +664,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-redis"
|
||||
version = "1.0.0b260319"
|
||||
version = "1.0.0b260311"
|
||||
source = { editable = "packages/redis" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
|
||||
Reference in New Issue
Block a user