Compare commits

..
Author SHA1 Message Date
alliscodeandCopilot b0a15914bf fix: resolve mypy redundant-cast errors while keeping pyright happy
Use cast(list[Any], x) with type: ignore[redundant-cast] comments to
satisfy both mypy (which considers casting Any redundant) and pyright
strict mode (which needs explicit casts to narrow Unknown types).

Also fix evaluator decorator check_name type annotation to be
explicitly str, resolving mypy str|Any|None mismatch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 15:25:07 -07:00
alliscodeandCopilot 45527eed29 Foundry Evals integration for Python
Merged and refactored eval module per Eduard's PR review:

- Merge _eval.py + _local_eval.py into single _evaluation.py
- Convert EvalItem from dataclass to regular class
- Rename to_dict() to to_eval_data()
- Convert _AgentEvalData to TypedDict
- Simplify check system: unified async pattern with isawaitable
- Parallelize checks and evaluators with asyncio.gather
- Add all/any mode to tool_called_check
- Fix bool(passed) truthy bug in _coerce_result
- Remove deprecated function_evaluator/async_function_evaluator aliases
- Remove _MinimalAgent, tighten evaluate_agent signature
- Set self.name in __init__ (LocalEvaluator, FoundryEvals)
- Limit FoundryEvals to AsyncOpenAI only
- Type project_client as AIProjectClient
- Remove NotImplementedError continuous eval code
- Add evaluation samples in 02-agents/ and 03-workflows/
- Update all imports and tests (167 passing)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 14:24:21 -07:00
100 changed files with 7447 additions and 3340 deletions
-207
View File
@@ -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()
-293
View File
@@ -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
-49
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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 []
+2 -2
View File
@@ -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,
)
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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],
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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
+1 -9
View File
@@ -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)
+16 -48
View File
@@ -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],
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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]
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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"
]
+2 -2
View File
@@ -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",
@@ -130,8 +130,8 @@ class FoundryLocalSettings(TypedDict, total=False):
class FoundryLocalClient(
FunctionInvocationLayer[FoundryLocalChatOptionsT],
ChatMiddlewareLayer[FoundryLocalChatOptionsT],
FunctionInvocationLayer[FoundryLocalChatOptionsT],
ChatTelemetryLayer[FoundryLocalChatOptionsT],
RawOpenAIChatClient[FoundryLocalChatOptionsT],
Generic[FoundryLocalChatOptionsT],
+2 -2
View File
@@ -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:
+2 -2
View File
@@ -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]
+2 -2
View File
@@ -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],
):
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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",
]
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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]
+4 -6
View File
@@ -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())
@@ -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
"""
+24 -24
View File
@@ -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'" },