From 100086a276fc188e4e7cc32211cc815327f3e2e5 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Thu, 19 Mar 2026 19:18:46 +0000 Subject: [PATCH 01/19] Add docker-in-docker feature to dev container (#4794) --- .devcontainer/dotnet/devcontainer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/dotnet/devcontainer.json b/.devcontainer/dotnet/devcontainer.json index 57bf3b4a11..4e13f9827b 100644 --- a/.devcontainer/dotnet/devcontainer.json +++ b/.devcontainer/dotnet/devcontainer.json @@ -3,6 +3,7 @@ "image": "mcr.microsoft.com/devcontainers/dotnet", "features": { "ghcr.io/devcontainers/features/azure-cli:1.2.9": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/devcontainers/features/github-cli:1": { "version": "2" }, From 4c287c2424b9f345bbed9d8015f32891f076cf52 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:13:37 -0700 Subject: [PATCH 02/19] Python: Fix MCP tool schema normalization for zero-argument tools missing 'properties' key (#4771) * Fix zero-argument MCP tool schema missing 'properties' key (#4540) MCP servers for zero-argument tools (e.g. matlab-mcp-core-server's detect_matlab_toolboxes) declare inputSchema as {"type": "object"} without a "properties" key. OpenAI's API requires "properties" to be present on object schemas, causing a 400 invalid_request_error. Normalize inputSchema at MCP ingestion in load_tools() to inject an empty "properties": {} when it is missing from object-type schemas. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4540: improve test robustness and add defensive guard - Look up loaded functions by name instead of index to avoid brittle ordering assumptions - Add negative-path test cases: non-object schema (type: string) and empty schema ({}) to verify guard clause skips them correctly - Assert original inputSchema dicts are not mutated by load_tools() - Add defensive guard for tool.inputSchema being None Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4540: Python: [Bug]: Local stdio MCP works for calculator but fails for official matlab-mcp-core-server on LM Studio /v1/responses --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/core/agent_framework/_mcp.py | 10 ++- python/packages/core/tests/core/test_mcp.py | 94 ++++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_mcp.py b/python/packages/core/agent_framework/_mcp.py index 5901e34dd9..062df5491c 100644 --- a/python/packages/core/agent_framework/_mcp.py +++ b/python/packages/core/agent_framework/_mcp.py @@ -902,13 +902,21 @@ class MCPTool: continue approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name) + # Normalize inputSchema: ensure "properties" exists for object schemas. + # Some MCP servers (e.g. zero-argument tools) omit "properties", + # which causes OpenAI API to reject the schema with a 400 error. + # Guard against non-conforming MCP servers that send inputSchema=None + # despite the MCP spec typing it as dict[str, Any]. + input_schema = dict(tool.inputSchema or {}) + if input_schema.get("type") == "object" and "properties" not in input_schema: + input_schema["properties"] = {} # Create FunctionTools out of each tool func: FunctionTool = FunctionTool( func=partial(self.call_tool, tool.name), name=local_name, description=tool.description or "", approval_mode=approval_mode, - input_model=tool.inputSchema, + input_model=input_schema, additional_properties={ _MCP_REMOTE_NAME_KEY: tool.name, _MCP_NORMALIZED_NAME_KEY: normalized_name, diff --git a/python/packages/core/tests/core/test_mcp.py b/python/packages/core/tests/core/test_mcp.py index b29ec1a794..fa9e1130f0 100644 --- a/python/packages/core/tests/core/test_mcp.py +++ b/python/packages/core/tests/core/test_mcp.py @@ -2042,6 +2042,100 @@ async def test_load_tools_with_pagination(): assert [f.name for f in tool._functions] == ["tool_1", "tool_2", "tool_3", "tool_4"] +async def test_load_tools_adds_properties_to_zero_arg_tool_schema(): + """Test that load_tools normalizes inputSchema for zero-argument MCP tools. + + Some MCP servers (e.g. matlab-mcp-core-server) declare zero-argument tools + with inputSchema={"type": "object"} and no "properties" key. OpenAI's API + requires "properties" to be present on object schemas, so load_tools must + inject an empty "properties" dict when it is missing. + """ + from unittest.mock import AsyncMock, MagicMock + + from agent_framework._mcp import MCPTool + + tool = MCPTool(name="test_tool") + + mock_session = AsyncMock() + tool.session = mock_session + tool.load_tools_flag = True + + original_zero_arg_schema = {"type": "object"} + original_string_schema = {"type": "string"} + original_empty_schema: dict[str, object] = {} + + page = MagicMock() + page.tools = [ + types.Tool( + name="zero_arg_tool", + description="A tool with no parameters", + inputSchema=original_zero_arg_schema, + ), + types.Tool( + name="normal_tool", + description="A tool with parameters", + inputSchema={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]}, + ), + types.Tool( + name="string_schema_tool", + description="A tool with a non-object schema", + inputSchema=original_string_schema, + ), + types.Tool( + name="empty_schema_tool", + description="A tool with an empty schema", + inputSchema=original_empty_schema, + ), + ] + + # Simulate a non-conforming MCP server that sends inputSchema=None. + # types.Tool requires inputSchema to be a dict, so we use a MagicMock. + none_schema_tool = MagicMock() + none_schema_tool.name = "none_schema_tool" + none_schema_tool.description = "A tool with None inputSchema" + none_schema_tool.inputSchema = None + page.tools.append(none_schema_tool) + page.nextCursor = None + + mock_session.list_tools = AsyncMock(return_value=page) + + await tool.load_tools() + + assert len(tool._functions) == 5 + + funcs_by_name = {f.name: f for f in tool._functions} + + # Zero-arg tool must have "properties" injected + zero_params = funcs_by_name["zero_arg_tool"].parameters() + assert "properties" in zero_params + assert zero_params["properties"] == {} + assert zero_params["type"] == "object" + + # Normal tool must retain its existing properties + normal_params = funcs_by_name["normal_tool"].parameters() + assert "properties" in normal_params + assert "x" in normal_params["properties"] + assert normal_params["required"] == ["x"] + + # Non-object schema must NOT have "properties" injected + string_params = funcs_by_name["string_schema_tool"].parameters() + assert "properties" not in string_params + assert string_params["type"] == "string" + + # Empty schema (no "type" key) must NOT have "properties" injected + empty_params = funcs_by_name["empty_schema_tool"].parameters() + assert "properties" not in empty_params + + # None inputSchema must produce an empty dict (guard against non-conforming servers) + none_params = funcs_by_name["none_schema_tool"].parameters() + assert none_params == {} + + # Original inputSchema dicts must not be mutated + assert "properties" not in original_zero_arg_schema + assert "properties" not in original_string_schema + assert "properties" not in original_empty_schema + + async def test_load_prompts_with_pagination(): """Test that load_prompts handles pagination correctly.""" from unittest.mock import AsyncMock, MagicMock From 47ead8475366c49a832e957d74caf23dcf508546 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:33:57 +0900 Subject: [PATCH 03/19] Python: Support `detail` field in OpenAI Chat API `image_url` payload (#4756) * Support detail field in OpenAI image_url payload (#4616) Include the optional 'detail' field from Content.additional_properties when building image_url payloads for the OpenAI Chat API, matching the existing pattern used for 'filename' in document file payloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Remove reproduction report Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify detail extraction from additional_properties (#4616) - Remove unnecessary hasattr check; additional_properties is always initialized as a dict on Content instances. - Use 'is not None' instead of truthy check to be more precise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Remove detail allowlist in chat client to align with responses client Replace the strict allowlist check ('low', 'high', 'auto') with an isinstance(detail, str) check so that any valid string detail value is passed through to OpenAI. This aligns the chat client behavior with the responses client, which passes detail through unconditionally. Also add test coverage for: - Future/unknown string detail values being passed through - Data URI images (covering the 'data' branch of the match) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework/openai/_chat_client.py | 6 +- .../tests/openai/test_openai_chat_client.py | 93 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 6df57fe428..bb7362a9bb 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -713,9 +713,13 @@ class RawOpenAIChatClient( # type: ignore[misc] "content": content.result if content.result is not None else "", } case "data" | "uri" if content.has_top_level_media_type("image"): + image_url_obj: dict[str, Any] = {"url": content.uri} + detail = content.additional_properties.get("detail") + if isinstance(detail, str): + image_url_obj["detail"] = detail return { "type": "image_url", - "image_url": {"url": content.uri}, + "image_url": image_url_obj, } case "data" | "uri" if content.has_top_level_media_type("audio"): if content.media_type and "wav" in content.media_type: diff --git a/python/packages/core/tests/openai/test_openai_chat_client.py b/python/packages/core/tests/openai/test_openai_chat_client.py index 3dc4c23c6d..86e8b115d6 100644 --- a/python/packages/core/tests/openai/test_openai_chat_client.py +++ b/python/packages/core/tests/openai/test_openai_chat_client.py @@ -462,6 +462,99 @@ 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: From 1272ec5adf3cc510402bf4b31c55b75284d7df7b Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:41:31 +0900 Subject: [PATCH 04/19] Add automated stale issue and PR follow-up ping workflow (#4776) * Add script to ping on stale issues/PRs * Add script to ping on stale issues/PRs * Fix stale issue/PR ping script review comments - Rename TEAM_NAME env var to TEAM_SLUG for clarity - Add actionable error messages for 403/404 team lookup failures - Add contents:read permission for actions/checkout - Use github.event.inputs context with fallback for scheduled runs - Pin PyGithub to 2.6.0 for reproducible builds - Fetch comments once in should_ping() to reduce API calls - Make ping() retry loop idempotent (track comment/label state) - Validate DAYS_THRESHOLD with helpful error for non-numeric input - Fix timezone bug: use astimezone() instead of replace(tzinfo=) - Add comprehensive unit tests (29 tests) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/stale_issue_pr_ping.py | 207 +++++++++++++++ .github/tests/test_stale_issue_pr_ping.py | 293 ++++++++++++++++++++++ .github/workflows/stale-issue-pr-ping.yml | 49 ++++ 3 files changed, 549 insertions(+) create mode 100644 .github/scripts/stale_issue_pr_ping.py create mode 100644 .github/tests/test_stale_issue_pr_ping.py create mode 100644 .github/workflows/stale-issue-pr-ping.yml diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py new file mode 100644 index 0000000000..0effcd5d75 --- /dev/null +++ b/.github/scripts/stale_issue_pr_ping.py @@ -0,0 +1,207 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Scan open issues and PRs for stale follow-ups from external authors. + +If a team member commented and the external author hasn't replied within +DAYS_THRESHOLD days, post a reminder comment and add the 'needs-info' label. +""" + +from __future__ import annotations + +import os +import sys +import time +from datetime import datetime, timezone + +from github import Auth, Github, GithubException +from github.Issue import Issue +from github.IssueComment import IssueComment + + +PING_COMMENT = ( + "@{author}, friendly reminder — this issue is waiting on your response. " + "Please share any updates when you get a chance. (This is an automated message.)" +) +LABEL = "needs-info" + + +def get_team_members(g: Github, org: str, team_slug: str) -> set[str]: + """Fetch active team member usernames.""" + try: + org_obj = g.get_organization(org) + team = org_obj.get_team_by_slug(team_slug) + return {m.login for m in team.get_members()} + except GithubException as exc: + if exc.status in (403, 404): + print( + f"ERROR: Failed to fetch team members for {org}/{team_slug} " + f"(HTTP {exc.status}). Check that the token has the 'read:org' " + f"scope and that the team slug '{team_slug}' is correct." + ) + else: + print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") + sys.exit(1) + except Exception as exc: + print(f"ERROR: Failed to fetch team members for {org}/{team_slug}: {exc}") + sys.exit(1) + + +def find_last_team_comment( + comments: list[IssueComment], team_members: set[str] +) -> IssueComment | None: + """Return the most recent comment from a team member, or None.""" + for comment in reversed(comments): + if comment.user and comment.user.login in team_members: + return comment + return None + + +def author_replied_after( + comments: list[IssueComment], author: str, after: datetime +) -> bool: + """Check if the issue author commented after the given timestamp.""" + for comment in comments: + if ( + comment.user + and comment.user.login == author + and comment.created_at > after + ): + return True + return False + + +def should_ping( + issue: Issue, + team_members: set[str], + days_threshold: int, + now: datetime, +) -> bool: + """Determine whether this issue/PR should be pinged.""" + author = issue.user.login + + # Skip if author is a team member + if author in team_members: + return False + + # Skip if already labeled + if any(label.name == LABEL for label in issue.labels): + return False + + # Skip if no comments at all + if issue.comments == 0: + return False + + # Fetch comments once for both lookups + comments = list(issue.get_comments()) + + # Find last team member comment + last_team_comment = find_last_team_comment(comments, team_members) + if last_team_comment is None: + return False + + # Skip if author replied after the last team comment + if author_replied_after(comments, author, last_team_comment.created_at): + return False + + # Check if enough days have passed + days_since = (now - last_team_comment.created_at.astimezone(timezone.utc)).days + if days_since < days_threshold: + return False + + return True + + +def ping(issue: Issue, dry_run: bool) -> bool: + """Post a reminder comment and add the needs-info label. Returns True on success.""" + author = issue.user.login + kind = "PR" if issue.pull_request else "Issue" + + if dry_run: + print(f" [DRY RUN] Would ping {kind} #{issue.number} (@{author})") + return True + + max_retries = 3 + commented = False + labeled = False + for attempt in range(1, max_retries + 1): + try: + if not commented: + issue.create_comment(PING_COMMENT.format(author=author)) + commented = True + if not labeled: + issue.add_to_labels(LABEL) + labeled = True + print(f" Pinged {kind} #{issue.number} (@{author})") + return True + except Exception as exc: + if attempt < max_retries: + wait = 2 ** attempt # 2s, 4s + print(f" WARN: Attempt {attempt}/{max_retries} failed for {kind} #{issue.number}: {exc}. Retrying in {wait}s...") + time.sleep(wait) + else: + print(f" ERROR: Failed to ping {kind} #{issue.number} after {max_retries} attempts: {exc}") + return False + + +def main() -> None: + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("ERROR: GITHUB_TOKEN environment variable is required") + sys.exit(1) + + repository = os.environ.get("GITHUB_REPOSITORY") + if not repository: + print("ERROR: GITHUB_REPOSITORY environment variable is required") + sys.exit(1) + + team_slug = os.environ.get("TEAM_SLUG") + if not team_slug: + print("ERROR: TEAM_SLUG environment variable is required") + sys.exit(1) + + days_threshold_raw = os.environ.get("DAYS_THRESHOLD", "4") + try: + days_threshold = int(days_threshold_raw) + except ValueError: + print(f"ERROR: DAYS_THRESHOLD must be a numeric value, got '{days_threshold_raw}'") + sys.exit(1) + dry_run = os.environ.get("DRY_RUN", "false").lower() == "true" + + org = repository.split("/")[0] + + if dry_run: + print("Running in DRY RUN mode — no comments or labels will be applied.\n") + + g = Github(auth=Auth.Token(token)) + repo = g.get_repo(repository) + + print(f"Fetching team members for {org}/{team_slug}...") + team_members = get_team_members(g, org, team_slug) + print(f"Found {len(team_members)} team members.\n") + + now = datetime.now(timezone.utc) + pinged = [] + failed = [] + scanned = 0 + + print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n") + + for issue in repo.get_issues(state="open"): + scanned += 1 + + if should_ping(issue, team_members, days_threshold, now): + if ping(issue, dry_run): + pinged.append(issue.number) + else: + failed.append(issue.number) + + print(f"\nDone. Scanned {scanned} items, pinged {len(pinged)}, failed {len(failed)}.") + if pinged: + print(f"Pinged: {', '.join(f'#{n}' for n in pinged)}") + if failed: + print(f"Failed: {', '.join(f'#{n}' for n in failed)}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/tests/test_stale_issue_pr_ping.py b/.github/tests/test_stale_issue_pr_ping.py new file mode 100644 index 0000000000..f114a84ed8 --- /dev/null +++ b/.github/tests/test_stale_issue_pr_ping.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for stale_issue_pr_ping.py.""" + +from __future__ import annotations + +import os +import sys +from datetime import datetime, timezone, timedelta +from unittest.mock import MagicMock, patch + +import pytest + +# Ensure the script directory is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from stale_issue_pr_ping import ( + LABEL, + PING_COMMENT, + author_replied_after, + find_last_team_comment, + get_team_members, + main, + ping, + should_ping, +) + +TEAM = {"alice", "bob"} +NOW = datetime(2026, 3, 15, 12, 0, 0, tzinfo=timezone.utc) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_comment(login: str | None, created_at: datetime) -> MagicMock: + """Create a mock IssueComment.""" + c = MagicMock() + if login is None: + c.user = None + else: + c.user = MagicMock() + c.user.login = login + c.created_at = created_at + return c + + +def _make_label(name: str) -> MagicMock: + lbl = MagicMock() + lbl.name = name + return lbl + + +def _make_issue( + author: str = "external", + labels: list[str] | None = None, + comment_count: int = 1, + comments: list[MagicMock] | None = None, + pull_request: bool = False, + number: int = 42, +) -> MagicMock: + issue = MagicMock() + issue.user = MagicMock() + issue.user.login = author + issue.number = number + issue.labels = [_make_label(n) for n in (labels or [])] + issue.comments = comment_count + issue.pull_request = MagicMock() if pull_request else None + if comments is not None: + issue.get_comments.return_value = comments + return issue + + +# --------------------------------------------------------------------------- +# find_last_team_comment +# --------------------------------------------------------------------------- + +class TestFindLastTeamComment: + def test_returns_last_team_comment(self): + c1 = _make_comment("alice", datetime(2026, 3, 1, tzinfo=timezone.utc)) + c2 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + c3 = _make_comment("bob", datetime(2026, 3, 3, tzinfo=timezone.utc)) + assert find_last_team_comment([c1, c2, c3], TEAM) is c3 + + def test_returns_none_when_no_team_comments(self): + c1 = _make_comment("external", datetime(2026, 3, 1, tzinfo=timezone.utc)) + assert find_last_team_comment([c1], TEAM) is None + + def test_returns_none_for_empty_list(self): + assert find_last_team_comment([], TEAM) is None + + def test_skips_deleted_user(self): + c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) + c2 = _make_comment("alice", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert find_last_team_comment([c1, c2], TEAM) is c2 + + def test_only_deleted_users(self): + c1 = _make_comment(None, datetime(2026, 3, 1, tzinfo=timezone.utc)) + assert find_last_team_comment([c1], TEAM) is None + + +# --------------------------------------------------------------------------- +# author_replied_after +# --------------------------------------------------------------------------- + +class TestAuthorRepliedAfter: + def test_author_replied(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is True + + def test_author_not_replied(self): + after = datetime(2026, 3, 5, tzinfo=timezone.utc) + c1 = _make_comment("external", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + def test_different_user_replied(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment("someone_else", datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + def test_deleted_user_comment(self): + after = datetime(2026, 3, 1, tzinfo=timezone.utc) + c1 = _make_comment(None, datetime(2026, 3, 2, tzinfo=timezone.utc)) + assert author_replied_after([c1], "external", after) is False + + +# --------------------------------------------------------------------------- +# should_ping +# --------------------------------------------------------------------------- + +class TestShouldPing: + def test_should_ping_stale_issue(self): + team_comment = _make_comment("alice", NOW - timedelta(days=5)) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is True + + def test_skip_team_member_author(self): + issue = _make_issue(author="alice", comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_already_labeled(self): + issue = _make_issue(labels=[LABEL], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_no_comments(self): + issue = _make_issue(comment_count=0) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_no_team_comment(self): + c = _make_comment("external", NOW - timedelta(days=5)) + issue = _make_issue(comments=[c], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_author_replied(self): + team_c = _make_comment("alice", NOW - timedelta(days=5)) + author_c = _make_comment("external", NOW - timedelta(days=3)) + issue = _make_issue(comments=[team_c, author_c], comment_count=2) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_skip_not_enough_days(self): + team_comment = _make_comment("alice", NOW - timedelta(days=2)) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is False + + def test_aware_datetime_handled(self): + """Timezone-aware datetimes should not be mangled by astimezone.""" + aware_dt = (NOW - timedelta(days=5)).replace(tzinfo=timezone.utc) + team_comment = _make_comment("alice", aware_dt) + issue = _make_issue(comments=[team_comment], comment_count=1) + assert should_ping(issue, TEAM, 4, NOW) is True + + def test_naive_datetime_handled(self): + """Naive datetimes (pre-PyGithub 2.x) should be handled by astimezone.""" + naive_dt = (NOW - timedelta(days=5)).replace(tzinfo=None) + team_comment = _make_comment("alice", naive_dt) + issue = _make_issue(comments=[team_comment], comment_count=1) + # astimezone on naive datetime treats it as local time; just verify no crash + should_ping(issue, TEAM, 4, NOW) + + +# --------------------------------------------------------------------------- +# ping +# --------------------------------------------------------------------------- + +class TestPing: + def test_dry_run(self, capsys): + issue = _make_issue() + assert ping(issue, dry_run=True) is True + issue.create_comment.assert_not_called() + assert "DRY RUN" in capsys.readouterr().out + + def test_success(self, capsys): + issue = _make_issue() + assert ping(issue, dry_run=False) is True + issue.create_comment.assert_called_once() + issue.add_to_labels.assert_called_once_with(LABEL) + + @patch("stale_issue_pr_ping.time.sleep") + def test_retry_on_failure(self, mock_sleep): + issue = _make_issue() + issue.create_comment.side_effect = [Exception("net error"), None] + assert ping(issue, dry_run=False) is True + assert issue.create_comment.call_count == 2 + mock_sleep.assert_called_once() + + @patch("stale_issue_pr_ping.time.sleep") + def test_idempotent_retry_skips_comment_on_label_failure(self, mock_sleep): + """If create_comment succeeds but add_to_labels fails, retry should not re-comment.""" + issue = _make_issue() + issue.add_to_labels.side_effect = [Exception("label error"), None] + assert ping(issue, dry_run=False) is True + # Comment should only be created once even though there were 2 attempts + assert issue.create_comment.call_count == 1 + assert issue.add_to_labels.call_count == 2 + + @patch("stale_issue_pr_ping.time.sleep") + def test_all_retries_fail(self, mock_sleep): + issue = _make_issue() + issue.create_comment.side_effect = Exception("permanent error") + assert ping(issue, dry_run=False) is False + assert issue.create_comment.call_count == 3 + + +# --------------------------------------------------------------------------- +# get_team_members +# --------------------------------------------------------------------------- + +class TestGetTeamMembers: + def test_success(self): + g = MagicMock() + member = MagicMock() + member.login = "alice" + g.get_organization.return_value.get_team_by_slug.return_value.get_members.return_value = [member] + assert get_team_members(g, "org", "my-team") == {"alice"} + + def test_403_error_message(self, capsys): + from github import GithubException + + g = MagicMock() + g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( + 403, {"message": "Forbidden"}, None + ) + with pytest.raises(SystemExit): + get_team_members(g, "org", "my-team") + out = capsys.readouterr().out + assert "read:org" in out + assert "403" in out + + def test_404_error_message(self, capsys): + from github import GithubException + + g = MagicMock() + g.get_organization.return_value.get_team_by_slug.side_effect = GithubException( + 404, {"message": "Not Found"}, None + ) + with pytest.raises(SystemExit): + get_team_members(g, "org", "bad-slug") + out = capsys.readouterr().out + assert "read:org" in out + assert "bad-slug" in out + + def test_generic_error(self, capsys): + g = MagicMock() + g.get_organization.side_effect = RuntimeError("boom") + with pytest.raises(SystemExit): + get_team_members(g, "org", "team") + + +# --------------------------------------------------------------------------- +# main – env var validation +# --------------------------------------------------------------------------- + +class TestMain: + @patch.dict(os.environ, { + "GITHUB_TOKEN": "tok", + "GITHUB_REPOSITORY": "org/repo", + "TEAM_SLUG": "my-team", + "DAYS_THRESHOLD": "abc", + }, clear=True) + def test_invalid_days_threshold(self, capsys): + with pytest.raises(SystemExit): + main() + assert "numeric" in capsys.readouterr().out + + @patch.dict(os.environ, { + "GITHUB_TOKEN": "tok", + "GITHUB_REPOSITORY": "org/repo", + }, clear=True) + def test_missing_team_slug(self, capsys): + with pytest.raises(SystemExit): + main() + assert "TEAM_SLUG" in capsys.readouterr().out diff --git a/.github/workflows/stale-issue-pr-ping.yml b/.github/workflows/stale-issue-pr-ping.yml new file mode 100644 index 0000000000..483706fc76 --- /dev/null +++ b/.github/workflows/stale-issue-pr-ping.yml @@ -0,0 +1,49 @@ +name: Stale issue and PR ping + +on: + schedule: + - cron: '0 0 * * *' # Midnight UTC daily + workflow_dispatch: + inputs: + days_threshold: + description: 'Days of silence before pinging the author' + required: false + default: '4' + dry_run: + description: 'Log what would be pinged without taking action' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' + +concurrency: + group: stale-issue-pr-ping + cancel-in-progress: true + +jobs: + ping_stale: + name: "Ping stale issues and PRs" + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Install dependencies + run: pip install PyGithub==2.6.0 + + - name: Run stale issue/PR ping + run: python .github/scripts/stale_issue_pr_ping.py + env: + GITHUB_TOKEN: ${{ secrets.GH_ACTIONS_PR_WRITE }} + TEAM_SLUG: ${{ secrets.DEVELOPER_TEAM }} + DAYS_THRESHOLD: ${{ github.event.inputs.days_threshold || '4' }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} From 4afc088f018aa5b8312e6a556996b18acb7eba1d Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:41:37 +0900 Subject: [PATCH 05/19] Python: Emit AG-UI events for MCP tool calls, results, and text reasoning (#4760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Python: Emit AG-UI events for MCP tool calls, results, and text reasoning Fixes #4213 — `_emit_content()` in the AG-UI layer only handled `text`, `function_call`, `function_result`, `function_approval_request`, `usage`, and `oauth_consent_request` content types. Foundry MCP content types (`mcp_server_tool_call`, `mcp_server_tool_result`) and `text_reasoning` fell through unhandled, producing no SSE events for AG-UI consumers. Added three new handler functions wired into `_emit_content()`: - `_emit_mcp_tool_call`: emits TOOL_CALL_START + TOOL_CALL_ARGS and tracks in FlowState for MESSAGES_SNAPSHOT inclusion - `_emit_mcp_tool_result`: emits TOOL_CALL_END + TOOL_CALL_RESULT with full FlowState cleanup mirroring `_emit_tool_result` - `_emit_text_reasoning`: emits the protocol-defined reasoning event sequence (ReasoningStart → MessageStart → MessageContent → MessageEnd → ReasoningEnd) with ReasoningEncryptedValueEvent for protected_data * Add HTTP round-trip tests for MCP tool and reasoning SSE events Exercises the full POST → SSE bytes → parse → validate pipeline for mcp_server_tool_call, mcp_server_tool_result, text_reasoning, and ReasoningEncryptedValueEvent content through FastAPI TestClient. * Fix _emit_mcp_tool_result missing predictive_handler support (#4213) - Add predictive_handler parameter to _emit_mcp_tool_result and mirror the apply_pending_updates + StateSnapshotEvent block from _emit_tool_result - Forward predictive_handler from _emit_content to _emit_mcp_tool_result - Add assertion for stored arguments in MCP tool call test - Add test for predictive handler state snapshot after MCP tool result Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Refactor MCP tool emit functions and add missing tests (#4213) - Extract _emit_tool_result_common shared helper to eliminate duplication between _emit_tool_result and _emit_mcp_tool_result - Remove server_name prefix from tool_call_name in _emit_mcp_tool_call; display_name now equals tool_name directly - Add test for tool_name fallback to 'mcp_tool' when tool_name is None - Add test for output=None fallback to empty string in _emit_mcp_tool_result Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback for #4213: review comment fixes --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_ag_ui/_run_common.py | 150 +++++++- .../ag-ui/tests/ag_ui/test_http_round_trip.py | 131 +++++++ python/packages/ag-ui/tests/ag_ui/test_run.py | 355 ++++++++++++++++++ 3 files changed, 624 insertions(+), 12 deletions(-) diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index cde338cbc7..0a9f4cea9c 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -12,6 +12,12 @@ from typing import Any, cast from ag_ui.core import ( BaseEvent, CustomEvent, + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, RunFinishedEvent, StateSnapshotEvent, TextMessageContentEvent, @@ -224,27 +230,28 @@ def _emit_tool_call( return events -def _emit_tool_result( - content: Content, +def _emit_tool_result_common( + call_id: str, + raw_result: Any, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None, ) -> list[BaseEvent]: - """Emit ToolCallResult events for function_result content.""" + """Shared helper for emitting ToolCallEnd + ToolCallResult events and performing FlowState cleanup. + + Both ``_emit_tool_result`` (standard function results) and ``_emit_mcp_tool_result`` + (MCP server tool results) delegate to this function. + """ events: list[BaseEvent] = [] - if not content.call_id: - return events + events.append(ToolCallEndEvent(tool_call_id=call_id)) + flow.tool_calls_ended.add(call_id) - events.append(ToolCallEndEvent(tool_call_id=content.call_id)) - flow.tool_calls_ended.add(content.call_id) - - raw_result = content.result if content.result is not None else "" result_content = raw_result if isinstance(raw_result, str) else json.dumps(make_json_safe(raw_result)) message_id = generate_event_id() events.append( ToolCallResultEvent( message_id=message_id, - tool_call_id=content.call_id, + tool_call_id=call_id, content=result_content, role="tool", ) @@ -254,7 +261,7 @@ def _emit_tool_result( { "id": message_id, "role": "tool", - "toolCallId": content.call_id, + "toolCallId": call_id, "content": result_content, } ) @@ -268,7 +275,7 @@ def _emit_tool_result( flow.tool_call_name = None if flow.message_id: - logger.debug("Closing text message (issue #3568 fix): message_id=%s", flow.message_id) + logger.debug("Closing text message: message_id=%s", flow.message_id) events.append(TextMessageEndEvent(message_id=flow.message_id)) flow.message_id = None flow.accumulated_text = "" @@ -276,6 +283,18 @@ def _emit_tool_result( return events +def _emit_tool_result( + content: Content, + flow: FlowState, + predictive_handler: PredictiveStateHandler | None = None, +) -> list[BaseEvent]: + """Emit ToolCallResult events for function_result content.""" + if not content.call_id: + return [] + raw_result = content.result if content.result is not None else "" + return _emit_tool_result_common(content.call_id, raw_result, flow, predictive_handler) + + def _emit_approval_request( content: Content, flow: FlowState, @@ -381,6 +400,107 @@ def _emit_oauth_consent(content: Content) -> list[BaseEvent]: ) +def _emit_mcp_tool_call(content: Content, flow: FlowState) -> list[BaseEvent]: + """Emit ToolCall start/args events for MCP server tool call content. + + MCP tool calls arrive as complete items (not streamed deltas), so we emit a + ``ToolCallStartEvent`` (and, when arguments are present, a ``ToolCallArgsEvent``) + immediately. This maps MCP-specific fields (tool_name, server_name) to the + same AG-UI ToolCall* events used by regular function calls, making MCP tool + execution visible to AG-UI consumers. Completion/end events are handled + separately by ``_emit_mcp_tool_result``. + """ + events: list[BaseEvent] = [] + + tool_call_id = content.call_id or generate_event_id() + tool_name = content.tool_name or "mcp_tool" + + display_name = tool_name + + events.append( + ToolCallStartEvent( + tool_call_id=tool_call_id, + tool_call_name=display_name, + parent_message_id=flow.message_id, + ) + ) + + # Serialize arguments + args_str = "" + if content.arguments: + args_str = ( + content.arguments if isinstance(content.arguments, str) else json.dumps(make_json_safe(content.arguments)) + ) + events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=args_str)) + + # Track in flow state for MESSAGES_SNAPSHOT + tool_entry = { + "id": tool_call_id, + "type": "function", + "function": {"name": display_name, "arguments": args_str}, + } + flow.pending_tool_calls.append(tool_entry) + flow.tool_calls_by_id[tool_call_id] = tool_entry + + return events + + +def _emit_mcp_tool_result( + content: Content, flow: FlowState, predictive_handler: PredictiveStateHandler | None = None +) -> list[BaseEvent]: + """Emit ToolCallResult events for MCP server tool result content. + + Delegates to the shared _emit_tool_result_common helper using content.output + (the MCP-specific result field) instead of content.result. + """ + if not content.call_id: + logger.warning("MCP tool result content missing call_id, skipping") + return [] + raw_output = content.output if content.output is not None else "" + return _emit_tool_result_common(content.call_id, raw_output, flow, predictive_handler) + + +def _emit_text_reasoning(content: Content) -> list[BaseEvent]: + """Emit AG-UI reasoning events for text_reasoning content. + + Uses the protocol-defined reasoning event types so that AG-UI consumers + such as CopilotKit can render reasoning natively. + + Only ``content.text`` is used for the visible reasoning message. If + ``content.protected_data`` is present it is emitted as a + ``ReasoningEncryptedValueEvent`` so that consumers can persist encrypted + reasoning for state continuity without conflating it with display text. + """ + text = content.text or "" + if not text and content.protected_data is None: + return [] + + message_id = content.id or generate_event_id() + + events: list[BaseEvent] = [ + ReasoningStartEvent(message_id=message_id), + ReasoningMessageStartEvent(message_id=message_id, role="assistant"), + ] + + if text: + events.append(ReasoningMessageContentEvent(message_id=message_id, delta=text)) + + events.append(ReasoningMessageEndEvent(message_id=message_id)) + + if content.protected_data is not None: + events.append( + ReasoningEncryptedValueEvent( + subtype="message", + entity_id=message_id, + encrypted_value=content.protected_data, + ) + ) + + events.append(ReasoningEndEvent(message_id=message_id)) + + return events + + def _emit_content( content: Any, flow: FlowState, @@ -402,5 +522,11 @@ def _emit_content( return _emit_usage(content) if content_type == "oauth_consent_request": return _emit_oauth_consent(content) + if content_type == "mcp_server_tool_call": + return _emit_mcp_tool_call(content, flow) + if content_type == "mcp_server_tool_result": + return _emit_mcp_tool_result(content, flow, predictive_handler) + if content_type == "text_reasoning": + return _emit_text_reasoning(content) logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type) return [] diff --git a/python/packages/ag-ui/tests/ag_ui/test_http_round_trip.py b/python/packages/ag-ui/tests/ag_ui/test_http_round_trip.py index 7e4712535c..5a86a6ff59 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_http_round_trip.py +++ b/python/packages/ag-ui/tests/ag_ui/test_http_round_trip.py @@ -213,3 +213,134 @@ def test_sse_response_headers() -> None: assert response.headers["content-type"] == "text/event-stream; charset=utf-8" assert response.headers.get("cache-control") == "no-cache" + + +# ── MCP tool call SSE round-trip ── + + +def test_mcp_tool_call_sse_round_trip() -> None: + """MCP tool call + result events survive SSE encoding/parsing round-trip.""" + app = _build_app_with_agent( + [ + AgentResponseUpdate( + contents=[ + Content.from_mcp_server_tool_call( + call_id="mcp-1", + tool_name="search", + server_name="brave", + arguments={"query": "weather"}, + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp-1", + output={"results": ["sunny"]}, + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_text(text="It's sunny!")], + role="assistant", + ), + ] + ) + client = TestClient(app) + response = client.post("/", json=USER_PAYLOAD) + + assert response.status_code == 200 + stream = parse_sse_to_event_stream(response.content) + stream.assert_bookends() + stream.assert_tool_calls_balanced() + stream.assert_text_messages_balanced() + stream.assert_no_run_error() + + # Verify MCP tool call details survive SSE encoding + start = stream.first("TOOL_CALL_START") + assert start.tool_call_name == "search" + assert start.tool_call_id == "mcp-1" + + # Verify the result came through + result = stream.first("TOOL_CALL_RESULT") + assert "sunny" in result.content + + +# ── Text reasoning SSE round-trip ── + + +def test_text_reasoning_sse_round_trip() -> None: + """Text reasoning events survive SSE encoding/parsing round-trip.""" + app = _build_app_with_agent( + [ + AgentResponseUpdate( + contents=[ + Content.from_text_reasoning( + id="reason-1", + text="The user wants weather info, I should use a tool.", + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_text(text="Let me check the weather.")], + role="assistant", + ), + ] + ) + client = TestClient(app) + response = client.post("/", json=USER_PAYLOAD) + + assert response.status_code == 200 + stream = parse_sse_to_event_stream(response.content) + stream.assert_bookends() + stream.assert_text_messages_balanced() + stream.assert_no_run_error() + stream.assert_has_type("REASONING_START") + stream.assert_has_type("REASONING_MESSAGE_CONTENT") + stream.assert_has_type("REASONING_END") + + # Verify reasoning content survives SSE encoding + raw_events = parse_sse_response(response.content) + reasoning_content = [e for e in raw_events if e["type"] == "REASONING_MESSAGE_CONTENT"] + assert len(reasoning_content) == 1 + assert "weather" in reasoning_content[0]["delta"] + + +def test_text_reasoning_with_encrypted_value_sse_round_trip() -> None: + """Reasoning with protected_data emits ReasoningEncryptedValue through SSE.""" + app = _build_app_with_agent( + [ + AgentResponseUpdate( + contents=[ + Content.from_text_reasoning( + id="reason-enc", + text="visible reasoning", + protected_data="encrypted-payload-abc123", + ) + ], + role="assistant", + ), + AgentResponseUpdate( + contents=[Content.from_text(text="Done.")], + role="assistant", + ), + ] + ) + client = TestClient(app) + response = client.post("/", json=USER_PAYLOAD) + + assert response.status_code == 200 + stream = parse_sse_to_event_stream(response.content) + stream.assert_bookends() + stream.assert_no_run_error() + stream.assert_has_type("REASONING_ENCRYPTED_VALUE") + + raw_events = parse_sse_response(response.content) + encrypted = [e for e in raw_events if e["type"] == "REASONING_ENCRYPTED_VALUE"] + assert len(encrypted) == 1 + assert encrypted[0]["encryptedValue"] == "encrypted-payload-abc123" + assert encrypted[0]["entityId"] == "reason-enc" + assert encrypted[0]["subtype"] == "message" diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 5a0cd1605c..ae8c5e85b0 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -5,6 +5,12 @@ import pytest from ag_ui.core import ( CustomEvent, + ReasoningEncryptedValueEvent, + ReasoningEndEvent, + ReasoningMessageContentEvent, + ReasoningMessageEndEvent, + ReasoningMessageStartEvent, + ReasoningStartEvent, TextMessageEndEvent, TextMessageStartEvent, ToolCallArgsEvent, @@ -25,7 +31,10 @@ from agent_framework_ag_ui._run_common import ( _build_run_finished_event, _emit_approval_request, _emit_content, + _emit_mcp_tool_call, + _emit_mcp_tool_result, _emit_text, + _emit_text_reasoning, _emit_tool_call, _emit_tool_result, _extract_resume_payload, @@ -991,3 +1000,349 @@ def test_emit_oauth_consent_request_no_link(): events = _emit_content(content, flow) assert len(events) == 0 + + +# ============================================================================ +# Tests for MCP tool call, MCP tool result, and text reasoning event emission +# ============================================================================ + + +class TestEmitMcpToolCall: + """Tests for _emit_mcp_tool_call function.""" + + def test_produces_start_and_args_events(self): + """MCP tool call emits ToolCallStart + ToolCallArgs events.""" + flow = FlowState() + content = Content.from_mcp_server_tool_call( + call_id="mcp_call_1", + tool_name="search", + server_name="brave", + arguments={"query": "weather"}, + ) + + events = _emit_mcp_tool_call(content, flow) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_START" + assert events[0].tool_call_id == "mcp_call_1" + assert events[0].tool_call_name == "search" + assert events[1].type == "TOOL_CALL_ARGS" + assert events[1].tool_call_id == "mcp_call_1" + assert "weather" in events[1].delta + + def test_tracks_in_flow_state(self): + """MCP tool call is tracked in flow.pending_tool_calls and tool_calls_by_id.""" + flow = FlowState() + content = Content.from_mcp_server_tool_call( + call_id="mcp_call_2", + tool_name="get_file", + arguments='{"path": "/tmp/test.txt"}', + ) + + _emit_mcp_tool_call(content, flow) + + assert len(flow.pending_tool_calls) == 1 + assert flow.pending_tool_calls[0]["id"] == "mcp_call_2" + assert "mcp_call_2" in flow.tool_calls_by_id + assert flow.tool_calls_by_id["mcp_call_2"]["function"]["name"] == "get_file" + assert flow.tool_calls_by_id["mcp_call_2"]["function"]["arguments"] == '{"path": "/tmp/test.txt"}' + + def test_no_server_name_uses_tool_name_only(self): + """Without server_name, display name is just tool_name.""" + flow = FlowState() + content = Content.from_mcp_server_tool_call( + call_id="mcp_call_3", + tool_name="list_files", + ) + + events = _emit_mcp_tool_call(content, flow) + + assert events[0].tool_call_name == "list_files" + + def test_no_arguments_skips_args_event(self): + """No arguments produces only ToolCallStart, no ToolCallArgs.""" + flow = FlowState() + content = Content.from_mcp_server_tool_call( + call_id="mcp_call_4", + tool_name="ping", + ) + + events = _emit_mcp_tool_call(content, flow) + + assert len(events) == 1 + assert events[0].type == "TOOL_CALL_START" + + def test_generates_id_when_missing(self): + """A tool_call_id is generated when call_id is None.""" + flow = FlowState() + content = Content(type="mcp_server_tool_call", tool_name="test_tool") + + events = _emit_mcp_tool_call(content, flow) + + assert len(events) >= 1 + assert events[0].tool_call_id is not None + assert events[0].tool_call_id != "" + assert events[0].tool_call_name == "test_tool" + + def test_missing_tool_name_falls_back_to_mcp_tool(self): + """When tool_name is None, the fallback 'mcp_tool' is used.""" + flow = FlowState() + content = Content(type="mcp_server_tool_call") + + events = _emit_mcp_tool_call(content, flow) + + assert len(events) >= 1 + assert events[0].tool_call_name == "mcp_tool" + + +class TestEmitMcpToolResult: + """Tests for _emit_mcp_tool_result function.""" + + def test_produces_end_and_result_events(self): + """MCP tool result emits ToolCallEnd + ToolCallResult events.""" + flow = FlowState() + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_1", + output={"results": [{"title": "Weather", "url": "https://example.com"}]}, + ) + + events = _emit_mcp_tool_result(content, flow) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[0].tool_call_id == "mcp_call_1" + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].tool_call_id == "mcp_call_1" + assert "Weather" in events[1].content + + def test_tracks_in_flow_state(self): + """MCP tool result is tracked in flow.tool_results and tool_calls_ended.""" + flow = FlowState() + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_5", + output="Success", + ) + + _emit_mcp_tool_result(content, flow) + + assert "mcp_call_5" in flow.tool_calls_ended + assert len(flow.tool_results) == 1 + assert flow.tool_results[0]["toolCallId"] == "mcp_call_5" + assert flow.tool_results[0]["content"] == "Success" + + def test_no_call_id_returns_empty(self): + """Missing call_id returns empty events list with a warning.""" + flow = FlowState() + content = Content(type="mcp_server_tool_result", output="data") + + events = _emit_mcp_tool_result(content, flow) + + assert events == [] + + def test_serializes_non_string_output(self): + """Non-string output is serialized to JSON.""" + flow = FlowState() + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_6", + output={"key": "value", "count": 42}, + ) + + events = _emit_mcp_tool_result(content, flow) + + result_event = events[1] + assert isinstance(result_event.content, str) + assert '"key": "value"' in result_event.content + + def test_output_none_falls_back_to_empty_string(self): + """When output is None (default), the result content is an empty string.""" + flow = FlowState() + content = Content(type="mcp_server_tool_result", call_id="mcp_call_none") + + events = _emit_mcp_tool_result(content, flow) + + assert len(events) == 2 + assert events[1].type == "TOOL_CALL_RESULT" + assert events[1].content == "" + + def test_resets_flow_state_like_emit_tool_result(self): + """MCP tool result performs same FlowState cleanup as _emit_tool_result.""" + flow = FlowState() + flow.tool_call_id = "mcp_call_7" + flow.tool_call_name = "brave/search" + flow.message_id = "open-msg-456" + flow.accumulated_text = "Let me search for that..." + + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_7", + output="search results", + ) + + events = _emit_mcp_tool_result(content, flow) + + assert flow.tool_call_id is None + assert flow.tool_call_name is None + assert flow.message_id is None + assert flow.accumulated_text == "" + + text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)] + assert len(text_end_events) == 1 + assert text_end_events[0].message_id == "open-msg-456" + + def test_no_open_message_skips_text_end(self): + """MCP tool result without open text message skips TextMessageEndEvent.""" + flow = FlowState() + flow.message_id = None + + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_8", + output="result", + ) + + events = _emit_mcp_tool_result(content, flow) + + text_end_events = [e for e in events if isinstance(e, TextMessageEndEvent)] + assert len(text_end_events) == 0 + + def test_predictive_handler_emits_state_snapshot(self): + """MCP tool result applies pending updates and emits StateSnapshotEvent when predictive_handler is set.""" + from unittest.mock import MagicMock + + from ag_ui.core import StateSnapshotEvent + + flow = FlowState() + flow.current_state = {"doc": "hello"} + content = Content.from_mcp_server_tool_result( + call_id="mcp_call_9", + output="done", + ) + + handler = MagicMock() + events = _emit_mcp_tool_result(content, flow, predictive_handler=handler) + + handler.apply_pending_updates.assert_called_once() + snapshot_events = [e for e in events if isinstance(e, StateSnapshotEvent)] + assert len(snapshot_events) == 1 + assert snapshot_events[0].snapshot == {"doc": "hello"} + + +class TestEmitTextReasoning: + """Tests for _emit_text_reasoning function.""" + + def test_produces_reasoning_events(self): + """Text reasoning emits the full reasoning event sequence.""" + content = Content.from_text_reasoning( + id="reason_1", + text="The user is asking about weather, so I should call the weather tool.", + ) + + events = _emit_text_reasoning(content) + + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) + assert events[0].message_id == "reason_1" + assert isinstance(events[1], ReasoningMessageStartEvent) + assert events[1].message_id == "reason_1" + assert events[1].role == "assistant" + assert isinstance(events[2], ReasoningMessageContentEvent) + assert events[2].message_id == "reason_1" + assert events[2].delta == "The user is asking about weather, so I should call the weather tool." + assert isinstance(events[3], ReasoningMessageEndEvent) + assert events[3].message_id == "reason_1" + assert isinstance(events[4], ReasoningEndEvent) + assert events[4].message_id == "reason_1" + + def test_protected_data_emits_encrypted_value_event(self): + """protected_data is emitted as a ReasoningEncryptedValueEvent.""" + content = Content.from_text_reasoning( + id="reason_2", + text="visible reasoning", + protected_data="encrypted metadata", + ) + + events = _emit_text_reasoning(content) + + encrypted_events = [e for e in events if isinstance(e, ReasoningEncryptedValueEvent)] + assert len(encrypted_events) == 1 + assert encrypted_events[0].subtype == "message" + assert encrypted_events[0].entity_id == "reason_2" + assert encrypted_events[0].encrypted_value == "encrypted metadata" + + def test_protected_data_only_emits_event(self): + """Content with only protected_data (no text) still emits reasoning events.""" + content = Content.from_text_reasoning( + protected_data="encrypted reasoning content", + ) + + events = _emit_text_reasoning(content) + + # Should have start, msg_start, msg_end, encrypted_value, end (no content event) + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) + assert isinstance(events[1], ReasoningMessageStartEvent) + assert isinstance(events[2], ReasoningMessageEndEvent) + assert isinstance(events[3], ReasoningEncryptedValueEvent) + assert events[3].encrypted_value == "encrypted reasoning content" + assert isinstance(events[4], ReasoningEndEvent) + + def test_empty_text_and_no_protected_data_returns_empty(self): + """Empty text and no protected_data returns no events.""" + content = Content.from_text_reasoning() + + events = _emit_text_reasoning(content) + + assert events == [] + + def test_generates_message_id_when_missing(self): + """When id is None, a message_id is generated.""" + content = Content.from_text_reasoning(text="thinking...") + + events = _emit_text_reasoning(content) + + assert len(events) == 5 + assert events[0].message_id is not None + assert events[0].message_id != "" + # All events share the same message_id + assert events[1].message_id == events[0].message_id + + +class TestEmitContentMcpRouting: + """Tests that _emit_content correctly routes MCP and reasoning types.""" + + def test_routes_mcp_server_tool_call(self): + """_emit_content dispatches mcp_server_tool_call to _emit_mcp_tool_call.""" + flow = FlowState() + content = Content.from_mcp_server_tool_call( + call_id="route_test_1", + tool_name="test_tool", + server_name="test_server", + ) + + events = _emit_content(content, flow) + + assert len(events) >= 1 + assert events[0].type == "TOOL_CALL_START" + assert events[0].tool_call_name == "test_tool" + + def test_routes_mcp_server_tool_result(self): + """_emit_content dispatches mcp_server_tool_result to _emit_mcp_tool_result.""" + flow = FlowState() + content = Content.from_mcp_server_tool_result( + call_id="route_test_2", + output="result data", + ) + + events = _emit_content(content, flow) + + assert len(events) == 2 + assert events[0].type == "TOOL_CALL_END" + assert events[1].type == "TOOL_CALL_RESULT" + + def test_routes_text_reasoning(self): + """_emit_content dispatches text_reasoning to _emit_text_reasoning.""" + flow = FlowState() + content = Content.from_text_reasoning(text="I need to think about this...") + + events = _emit_content(content, flow) + + assert len(events) == 5 + assert isinstance(events[0], ReasoningStartEvent) From cefda44283a2beee84e06af14fe65e19b5e2fe84 Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:41:46 +0900 Subject: [PATCH 06/19] Python: Emit TOOL_CALL_RESULT events when resuming after tool approval (#4758) * Emit TOOL_CALL_RESULT events on approval resume (#4589) When a tool call is approved via the interrupt/resume flow, _resolve_approval_responses executes the tool and injects the result into the messages array, but no TOOL_CALL_RESULT SSE event was yielded to the client. Changes: - _resolve_approval_responses now returns the list of resolved function_result Content objects instead of None - run_agent_stream yields ToolCallResultEvent for each resolved approval result after RunStartedEvent is emitted - Add ToolCallResultEvent to ag_ui.core imports in _agent_run.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * fix(ag-ui): address PR review feedback for #4589 1. _resolve_approval_responses now returns only approved results (not rejections) so TOOL_CALL_RESULT events are emitted only for executed tools. Rejection results are still written into message history. 2. Emit resolved TOOL_CALL_RESULT events in the no-updates fallback RUN_STARTED path so approval results are never lost. 3. Rewrite tests to use real FunctionTool with func and approval_mode='always_require' via StubAgent default_options, verifying actual tool execution output in TOOL_CALL_RESULT content. Added test for rejection not emitting TOOL_CALL_RESULT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix #4589: clean up approval resolution and add missing tests - Extract duplicated TOOL_CALL_RESULT emission block into _make_approval_tool_result_events helper to prevent drift - Remove dead rejection_results construction in _resolve_approval_responses; _replace_approval_contents_with_results already handles rejections inline - Pass only approved_results (not all_results) to clarify the contract - Add mixed approve/reject test validating the core splitting logic - Add zero-updates test covering the no-updates fallback emission path - Add direct unit test for _resolve_approval_responses return value Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Fix import sorting lint error in test_approval_result_event.py Add blank line between first-party and third-party import groups to satisfy ruff I001 rule. Fixes #4589 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ag-ui/agent_framework_ag_ui/_agent_run.py | 59 ++- .../tests/ag_ui/test_approval_result_event.py | 450 ++++++++++++++++++ 2 files changed, 492 insertions(+), 17 deletions(-) create mode 100644 python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py index f0e70e46b3..4b00330283 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_agent_run.py @@ -21,6 +21,7 @@ from ag_ui.core import ( TextMessageStartEvent, ToolCallArgsEvent, ToolCallEndEvent, + ToolCallResultEvent, ToolCallStartEvent, ) from agent_framework import ( @@ -369,6 +370,24 @@ def _handle_step_based_approval(messages: list[Any]) -> list[BaseEvent]: return events +def _make_approval_tool_result_events(resolved_approval_results: list[Content]) -> list[ToolCallResultEvent]: + """Build TOOL_CALL_RESULT events for tools executed during approval resolution.""" + events: list[ToolCallResultEvent] = [] + for resolved in resolved_approval_results: + if resolved.call_id: + raw = resolved.result if resolved.result is not None else "" + result_str = raw if isinstance(raw, str) else json.dumps(make_json_safe(raw)) + events.append( + ToolCallResultEvent( + message_id=generate_event_id(), + tool_call_id=resolved.call_id, + content=result_str, + role="tool", + ) + ) + return events + + def _evict_oldest_approvals(registry: dict[str, str], max_size: int = 10_000) -> None: """Evict the oldest entries from the pending-approvals registry (LRU). @@ -391,7 +410,7 @@ async def _resolve_approval_responses( run_kwargs: dict[str, Any], pending_approvals: dict[str, str] | None = None, thread_id: str = "", -) -> None: +) -> list[Content]: """Execute approved function calls and replace approval content with results. This modifies the messages list in place, replacing function_approval_response @@ -407,10 +426,16 @@ async def _resolve_approval_responses( When provided, every approval response is validated against this registry to prevent bypass, function name spoofing, and replay. thread_id: The conversation thread ID used to scope registry keys. + + Returns: + List of approved function_result Content objects only (empty if no + approvals). Rejection results are written into the message history + but are *not* included in the return value because they should not + be emitted as TOOL_CALL_RESULT events. """ fcc_todo = _collect_approval_responses(messages) if not fcc_todo: - return + return [] approved_responses = [resp for resp in fcc_todo.values() if resp.approved] rejected_responses = [resp for resp in fcc_todo.values() if not resp.approved] @@ -493,31 +518,23 @@ async def _resolve_approval_responses( logger.exception("Failed to execute approved tool calls; injecting error results: %s", e) approved_function_results = [] - # Build normalized results for approved responses - normalized_results: list[Content] = [] + # Build results for approved responses (used for TOOL_CALL_RESULT event emission) + approved_results: list[Content] = [] for idx, approval in enumerate(approved_responses): if ( idx < len(approved_function_results) and getattr(approved_function_results[idx], "type", None) == "function_result" ): - normalized_results.append(approved_function_results[idx]) + approved_results.append(approved_function_results[idx]) continue # Get call_id from function_call if present, otherwise use approval.id func_call = approval.function_call call_id = (func_call.call_id if func_call else None) or approval.id or "" - normalized_results.append( + approved_results.append( Content.from_function_result(call_id=call_id, result="Error: Tool call invocation failed.") ) - # Build rejection results - for rejection in rejected_responses: - func_call = rejection.function_call - call_id = (func_call.call_id if func_call else None) or rejection.id or "" - normalized_results.append( - Content.from_function_result(call_id=call_id, result="Error: Tool call invocation was rejected by user.") - ) - - _replace_approval_contents_with_results(messages, fcc_todo, normalized_results) # type: ignore + _replace_approval_contents_with_results(messages, fcc_todo, approved_results) # type: ignore # Post-process: Convert user messages with function_result content to proper tool messages. # After _replace_approval_contents_with_results, approved tool calls have their results @@ -525,6 +542,8 @@ async def _resolve_approval_responses( # This transformation ensures the message history is valid for the LLM provider. _convert_approval_results_to_tool_messages(messages) + return approved_results + def _convert_approval_results_to_tool_messages(messages: list[Message]) -> None: """Convert function_result content in user messages to proper tool messages. @@ -787,7 +806,9 @@ async def run_agent_stream( # Resolve approval responses (execute approved tools, replace approvals with results) # This must happen before running the agent so it sees the tool results tools_for_execution = tools if tools is not None else server_tools - await _resolve_approval_responses(messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id) + resolved_approval_results = await _resolve_approval_responses( + messages, tools_for_execution, agent, run_kwargs, pending_approvals, thread_id + ) # Defense-in-depth: replace approval payloads in snapshot with actual tool results # so CopilotKit does not re-send stale approval content on subsequent turns. @@ -851,6 +872,9 @@ async def run_agent_stream( yield StateSnapshotEvent(snapshot=flow.current_state) run_started_emitted = True + for event in _make_approval_tool_result_events(resolved_approval_results): + yield event + # Feature #4: Detect tool-only messages (no text content) # Emit TextMessageStartEvent to create message context for tool calls if not flow.message_id and _has_only_tool_calls(update.contents): @@ -905,7 +929,8 @@ async def run_agent_stream( if state_schema and flow.current_state: yield StateSnapshotEvent(snapshot=flow.current_state) - # Process structured output if response_format is set + for event in _make_approval_tool_result_events(resolved_approval_results): + yield event if response_format is not None and all_updates: from agent_framework import AgentResponse from pydantic import BaseModel diff --git a/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py b/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py new file mode 100644 index 0000000000..35133ecf79 --- /dev/null +++ b/python/packages/ag-ui/tests/ag_ui/test_approval_result_event.py @@ -0,0 +1,450 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for TOOL_CALL_RESULT event emission on approval resume flows.""" + +from __future__ import annotations + +import json +from typing import Any + +from agent_framework import AgentResponseUpdate, Content, FunctionTool +from conftest import StubAgent + +from agent_framework_ag_ui._agent import AgentConfig +from agent_framework_ag_ui._agent_run import run_agent_stream + + +def _make_weather_tool() -> FunctionTool: + """Create a real executable weather tool with approval_mode='always_require'.""" + + def get_weather(city: str) -> str: + return f"Sunny in {city}" + + return FunctionTool( + name="get_weather", + description="Get the weather for a city", + func=get_weather, + approval_mode="always_require", + ) + + +async def test_approval_resume_emits_tool_call_result() -> None: + """After approving a tool call, the resume stream should contain a TOOL_CALL_RESULT event. + + The message format follows the AG-UI approval pattern: + - assistant message with tool_calls + - tool message with {"accepted": true} content and toolCallId + """ + tool_name = "get_weather" + call_id = "call_abc123" + weather_tool = _make_weather_tool() + + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="The weather is sunny.")], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + config = AgentConfig() + + # Build resume messages: user query, assistant tool call, approval response + resume_messages: list[dict[str, Any]] = [ + {"role": "user", "content": "What's the weather in Seattle?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps({"city": "Seattle"}), + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({"accepted": True}), + "toolCallId": call_id, + }, + ] + + input_data: dict[str, Any] = { + "thread_id": "thread-approval-result", + "run_id": "run-resume", + "messages": resume_messages, + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + event_types = [getattr(e, "type", None) for e in events] + + assert "RUN_STARTED" in event_types, f"Expected RUN_STARTED, got types: {event_types}" + assert "RUN_FINISHED" in event_types, f"Expected RUN_FINISHED, got types: {event_types}" + + # TOOL_CALL_RESULT must be present for the approved tool + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + + assert len(tool_result_events) > 0, ( + f"Expected at least one TOOL_CALL_RESULT event for the approved tool, " + f"but found none. Event types in stream: {event_types}" + ) + + result_event = tool_result_events[0] + assert result_event.tool_call_id == call_id, ( + f"Expected TOOL_CALL_RESULT with tool_call_id={call_id}, got tool_call_id={result_event.tool_call_id}" + ) + # Verify the result contains the actual tool execution output + assert result_event.content == "Sunny in Seattle" + + +async def test_approval_resume_result_has_content() -> None: + """TOOL_CALL_RESULT event from an approved tool should contain the execution result.""" + tool_name = "get_weather" + call_id = "call_content_check" + weather_tool = _make_weather_tool() + + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Done.")], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + config = AgentConfig() + + resume_messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Check the weather"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps({"city": "Portland"}), + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({"accepted": True}), + "toolCallId": call_id, + }, + ] + + input_data: dict[str, Any] = { + "thread_id": "thread-result-content", + "run_id": "run-resume-2", + "messages": resume_messages, + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + assert len(tool_result_events) == 1 + + result_event = tool_result_events[0] + assert result_event.tool_call_id == call_id + assert result_event.role == "tool" + # Verify the result contains the actual tool execution output (string returned directly) + assert result_event.content == "Sunny in Portland" + + +async def test_no_approval_no_extra_tool_result() -> None: + """When no approval response is present, no extra TOOL_CALL_RESULT events should be emitted.""" + agent = StubAgent(updates=[AgentResponseUpdate(contents=[Content.from_text(text="Hello.")], role="assistant")]) + config = AgentConfig() + + input_data: dict[str, Any] = { + "thread_id": "thread-no-approval", + "run_id": "run-normal", + "messages": [{"role": "user", "content": "Hi"}], + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + assert len(tool_result_events) == 0, f"Unexpected TOOL_CALL_RESULT events: {tool_result_events}" + + +async def test_rejection_does_not_emit_tool_call_result() -> None: + """Rejected tool calls should not produce TOOL_CALL_RESULT events.""" + tool_name = "get_weather" + call_id = "call_rejected" + weather_tool = _make_weather_tool() + + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="OK, I won't check.")], role="assistant")], + default_options={"tools": [weather_tool]}, + ) + config = AgentConfig() + + resume_messages: list[dict[str, Any]] = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps({"city": "Denver"}), + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({"accepted": False}), + "toolCallId": call_id, + }, + ] + + input_data: dict[str, Any] = { + "thread_id": "thread-rejection", + "run_id": "run-rejected", + "messages": resume_messages, + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + assert len(tool_result_events) == 0, ( + f"Expected no TOOL_CALL_RESULT for rejected tool, got {len(tool_result_events)}" + ) + + +def _make_temperature_tool() -> FunctionTool: + """Create a real executable temperature tool with approval_mode='always_require'.""" + + def get_temperature(city: str) -> str: + return f"72F in {city}" + + return FunctionTool( + name="get_temperature", + description="Get the temperature for a city", + func=get_temperature, + approval_mode="always_require", + ) + + +async def test_mixed_approve_reject_emits_only_approved_tool_result() -> None: + """When one tool call is approved and another rejected, only the approved one produces a TOOL_CALL_RESULT event.""" + weather_tool = _make_weather_tool() + temperature_tool = _make_temperature_tool() + approved_call_id = "call_approved" + rejected_call_id = "call_rejected" + + agent = StubAgent( + updates=[AgentResponseUpdate(contents=[Content.from_text(text="Here are the results.")], role="assistant")], + default_options={"tools": [weather_tool, temperature_tool]}, + ) + config = AgentConfig() + + resume_messages: list[dict[str, Any]] = [ + {"role": "user", "content": "Weather and temperature in Seattle?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": approved_call_id, + "type": "function", + "function": { + "name": "get_weather", + "arguments": json.dumps({"city": "Seattle"}), + }, + }, + { + "id": rejected_call_id, + "type": "function", + "function": { + "name": "get_temperature", + "arguments": json.dumps({"city": "Seattle"}), + }, + }, + ], + }, + { + "role": "tool", + "content": json.dumps({"accepted": True}), + "toolCallId": approved_call_id, + }, + { + "role": "tool", + "content": json.dumps({"accepted": False}), + "toolCallId": rejected_call_id, + }, + ] + + input_data: dict[str, Any] = { + "thread_id": "thread-mixed", + "run_id": "run-mixed", + "messages": resume_messages, + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + + # Only the approved tool call should produce a TOOL_CALL_RESULT event + assert len(tool_result_events) == 1, ( + f"Expected exactly 1 TOOL_CALL_RESULT (approved only), got {len(tool_result_events)}" + ) + assert tool_result_events[0].tool_call_id == approved_call_id + assert tool_result_events[0].content == "Sunny in Seattle" + + +async def test_approval_resume_zero_updates_emits_tool_result() -> None: + """When the agent produces zero updates, TOOL_CALL_RESULT events should still be emitted via the fallback path.""" + tool_name = "get_weather" + call_id = "call_zero_updates" + weather_tool = _make_weather_tool() + + agent = StubAgent( + updates=[], + default_options={"tools": [weather_tool]}, + ) + config = AgentConfig() + + resume_messages: list[dict[str, Any]] = [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps({"city": "Boston"}), + }, + } + ], + }, + { + "role": "tool", + "content": json.dumps({"accepted": True}), + "toolCallId": call_id, + }, + ] + + input_data: dict[str, Any] = { + "thread_id": "thread-zero-updates", + "run_id": "run-zero-updates", + "messages": resume_messages, + } + + events: list[Any] = [] + async for event in run_agent_stream(input_data, agent, config): + events.append(event) + + event_types = [getattr(e, "type", None) for e in events] + assert "RUN_STARTED" in event_types + + tool_result_events = [e for e in events if getattr(e, "type", None) == "TOOL_CALL_RESULT"] + assert len(tool_result_events) == 1, ( + f"Expected 1 TOOL_CALL_RESULT in zero-updates fallback path, got {len(tool_result_events)}" + ) + assert tool_result_events[0].tool_call_id == call_id + assert tool_result_events[0].content == "Sunny in Boston" + + +async def test_resolve_approval_responses_returns_only_approved() -> None: + """_resolve_approval_responses should return only approved results; rejection results go into messages only.""" + from agent_framework import Message + + from agent_framework_ag_ui._agent_run import _resolve_approval_responses + + weather_tool = _make_weather_tool() + temperature_tool = _make_temperature_tool() + approved_call_id = "call_a" + rejected_call_id = "call_r" + + messages: list[Any] = [ + Message(role="user", contents=[Content.from_text(text="Hi")]), + Message( + role="assistant", + contents=[ + Content( + type="function_approval_request", + id=approved_call_id, + function_call=Content( + type="function_call", + name="get_weather", + call_id=approved_call_id, + arguments='{"city": "NYC"}', + ), + ), + Content( + type="function_approval_request", + id=rejected_call_id, + function_call=Content( + type="function_call", + name="get_temperature", + call_id=rejected_call_id, + arguments='{"city": "NYC"}', + ), + ), + ], + ), + Message( + role="user", + contents=[ + Content( + type="function_approval_response", + id=approved_call_id, + approved=True, + function_call=Content( + type="function_call", + name="get_weather", + call_id=approved_call_id, + arguments='{"city": "NYC"}', + ), + ), + Content( + type="function_approval_response", + id=rejected_call_id, + approved=False, + function_call=Content( + type="function_call", + name="get_temperature", + call_id=rejected_call_id, + arguments='{"city": "NYC"}', + ), + ), + ], + ), + ] + + agent = StubAgent( + updates=[], + default_options={"tools": [weather_tool, temperature_tool]}, + ) + + results = await _resolve_approval_responses(messages, [weather_tool, temperature_tool], agent, {}) + + # Return value should only contain approved results + assert len(results) == 1 + assert results[0].call_id == approved_call_id + assert results[0].type == "function_result" + + # Rejection result should be written into messages (by _replace_approval_contents_with_results) + all_contents = [c for msg in messages for c in msg.contents] + rejection_results = [c for c in all_contents if c.type == "function_result" and c.call_id == rejected_call_id] + assert len(rejection_results) == 1 + assert "rejected" in str(rejection_results[0].result).lower() From 0cd40f8354e91f3e89cbc96e2d8740cc40a6af2b Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 20 Mar 2026 01:43:37 +0100 Subject: [PATCH 07/19] Python: [BREAKING] Refactor middleware layering and split Anthropic raw client (#4746) * [BREAKING] Refactor middleware layering and raw clients Reorder chat client layers so function invocation wraps chat middleware, and chat middleware stays outside telemetry while still running for each inner model call. Add middleware pipeline caching, refresh docs and samples, and split Anthropic into raw and public clients to match the standard layering model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten typing ignores in ancillary modules Add targeted typing ignores in workflow visualization and lab modules so pyright stays clean alongside the middleware refactor work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix categorize_middleware to unpack tuple/Sequence and use relative MRO assertions - Broaden isinstance check in categorize_middleware from list to Sequence so tuples and other Sequence types are properly unpacked instead of being appended as a single item. - Replace fragile hardcoded MRO index assertions in anthropic test with relative ordering via mro.index(). - Add regression tests for categorize_middleware with tuple, list, and None inputs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix middleware string decomposition, add middleware param to FunctionInvocationLayer, and add tests (#4710) - Guard categorize_middleware Sequence check against str/bytes to prevent character-by-character decomposition of accidentally passed strings - Add explicit middleware parameter to FunctionInvocationLayer.get_response and merge it into client_kwargs before categorization, fixing the inconsistency where only OpenAIChatClient supported this parameter - Add assertions that RawAnthropicClient does not inherit convenience layers - Add chat middleware cache test with non-empty base middleware - Add tests for single unwrapped middleware item and string input Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply pre-commit auto-fixes * Apply pre-commit auto-fixes * Address review feedback for #4710: review comment fixes --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot --- .../ag-ui/agent_framework_ag_ui/_client.py | 2 +- python/packages/ag-ui/tests/ag_ui/conftest.py | 4 +- .../agent_framework_anthropic/__init__.py | 3 +- .../agent_framework_anthropic/_chat_client.py | 131 ++++++++-- .../anthropic/tests/test_anthropic_client.py | 20 +- .../agent_framework_azure_ai/_chat_client.py | 2 +- .../agent_framework_azure_ai/_client.py | 8 +- .../tests/test_azure_ai_agent_client.py | 6 + .../agent_framework_bedrock/_chat_client.py | 2 +- .../packages/core/agent_framework/_clients.py | 11 +- .../core/agent_framework/_middleware.py | 64 +++-- .../packages/core/agent_framework/_tools.py | 64 +++-- .../core/agent_framework/_workflows/_viz.py | 4 +- .../agent_framework/azure/_chat_client.py | 2 +- .../azure/_responses_client.py | 2 +- .../core/agent_framework/observability.py | 18 +- .../openai/_assistants_client.py | 2 +- .../agent_framework/openai/_chat_client.py | 25 +- .../openai/_responses_client.py | 8 +- python/packages/core/tests/core/conftest.py | 4 +- .../packages/core/tests/core/test_clients.py | 3 +- .../core/test_function_invocation_logic.py | 10 +- .../test_kwargs_propagation_to_ai_function.py | 2 +- .../core/tests/core/test_middleware.py | 47 ++++ .../tests/core/test_middleware_with_agent.py | 91 +++++++ .../tests/core/test_middleware_with_chat.py | 242 +++++++++++++++++- .../core/tests/core/test_observability.py | 8 +- .../_foundry_local_client.py | 2 +- .../lab/gaia/agent_framework_lab_gaia/gaia.py | 2 +- .../agent_framework_lab_lightning/__init__.py | 4 +- .../agent_framework_ollama/_chat_client.py | 2 +- .../orchestrations/tests/test_handoff.py | 16 +- python/samples/02-agents/auto_retry.py | 10 +- .../chat_client/custom_chat_client.py | 7 +- python/samples/02-agents/middleware/README.md | 37 +++ .../agent_and_run_level_middleware.py | 8 +- .../middleware/usage_tracking_middleware.py | 185 +++++++++++++ .../advanced_manual_setup_console_output.py | 14 +- .../observability/advanced_zero_code.py | 10 +- .../02-agents/providers/custom/README.md | 8 +- .../agent_with_local_tools/main.py | 1 - 41 files changed, 936 insertions(+), 155 deletions(-) create mode 100644 python/samples/02-agents/middleware/README.md create mode 100644 python/samples/02-agents/middleware/usage_tracking_middleware.py diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_client.py b/python/packages/ag-ui/agent_framework_ag_ui/_client.py index d2fb59bbb6..7a1b974a38 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_client.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_client.py @@ -111,8 +111,8 @@ def _apply_server_function_call_unwrap(client: BaseChatClientT) -> BaseChatClien @_apply_server_function_call_unwrap class AGUIChatClient( - ChatMiddlewareLayer[AGUIChatOptionsT], FunctionInvocationLayer[AGUIChatOptionsT], + ChatMiddlewareLayer[AGUIChatOptionsT], ChatTelemetryLayer[AGUIChatOptionsT], BaseChatClient[AGUIChatOptionsT], Generic[AGUIChatOptionsT], diff --git a/python/packages/ag-ui/tests/ag_ui/conftest.py b/python/packages/ag-ui/tests/ag_ui/conftest.py index 42a6967371..744196dbdf 100644 --- a/python/packages/ag-ui/tests/ag_ui/conftest.py +++ b/python/packages/ag-ui/tests/ag_ui/conftest.py @@ -45,8 +45,8 @@ def pytest_configure() -> None: class StreamingChatClientStub( - ChatMiddlewareLayer[OptionsCoT], FunctionInvocationLayer[OptionsCoT], + ChatMiddlewareLayer[OptionsCoT], ChatTelemetryLayer[OptionsCoT], BaseChatClient[OptionsCoT], Generic[OptionsCoT], @@ -54,7 +54,7 @@ class StreamingChatClientStub( """Typed streaming stub that satisfies SupportsChatGetResponse.""" def __init__(self, stream_fn: StreamFn, response_fn: ResponseFn | None = None) -> None: - super().__init__(function_middleware=[]) + super().__init__(middleware=[]) self._stream_fn = stream_fn self._response_fn = response_fn self.last_session: AgentSession | None = None diff --git a/python/packages/anthropic/agent_framework_anthropic/__init__.py b/python/packages/anthropic/agent_framework_anthropic/__init__.py index 706740a127..ad0cff9648 100644 --- a/python/packages/anthropic/agent_framework_anthropic/__init__.py +++ b/python/packages/anthropic/agent_framework_anthropic/__init__.py @@ -2,7 +2,7 @@ import importlib.metadata -from ._chat_client import AnthropicChatOptions, AnthropicClient +from ._chat_client import AnthropicChatOptions, AnthropicClient, RawAnthropicClient try: __version__ = importlib.metadata.version(__name__) @@ -12,5 +12,6 @@ except importlib.metadata.PackageNotFoundError: __all__ = [ "AnthropicChatOptions", "AnthropicClient", + "RawAnthropicClient", "__version__", ] diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index a1915a69fb..b3b61a4640 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -68,6 +68,7 @@ else: __all__ = [ "AnthropicChatOptions", "AnthropicClient", + "RawAnthropicClient", "ThinkingConfig", ] @@ -210,14 +211,24 @@ class AnthropicSettings(TypedDict, total=False): chat_model_id: str | None -class AnthropicClient( - ChatMiddlewareLayer[AnthropicOptionsT], - FunctionInvocationLayer[AnthropicOptionsT], - ChatTelemetryLayer[AnthropicOptionsT], +class RawAnthropicClient( BaseChatClient[AnthropicOptionsT], Generic[AnthropicOptionsT], ): - """Anthropic Chat client with middleware, telemetry, and function invocation support.""" + """Raw Anthropic chat client without middleware, telemetry, or function invocation support. + + Warning: + **This class should not normally be used directly.** It does not include middleware, + telemetry, or function invocation support that you most likely need. If you do use it, + you should consider which additional layers to apply. There is a defined ordering that + you should follow: + + 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware + 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry + 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry + + Use ``AnthropicClient`` instead for a fully-featured client with all layers applied. + """ OTEL_PROVIDER_NAME: ClassVar[str] = "anthropic" # type: ignore[reportIncompatibleVariableOverride, misc] @@ -229,12 +240,10 @@ class AnthropicClient( anthropic_client: AsyncAnthropic | None = None, additional_beta_flags: list[str] | None = None, additional_properties: dict[str, Any] | None = None, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: - """Initialize an Anthropic Agent client. + """Initialize a raw Anthropic client. Keyword Args: api_key: The Anthropic API key to use for authentication. @@ -245,15 +254,13 @@ class AnthropicClient( additional_beta_flags: Additional beta flags to enable on the client. Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25". additional_properties: Additional properties stored on the client instance. - middleware: Optional middleware to apply to the client. - function_invocation_configuration: Optional function invocation configuration override. env_file_path: Path to environment file for loading settings. env_file_encoding: Encoding of the environment file. Examples: .. code-block:: python - from agent_framework.anthropic import AnthropicClient + from agent_framework.anthropic import RawAnthropicClient from azure.identity.aio import DefaultAzureCredential # Using environment variables @@ -261,13 +268,13 @@ class AnthropicClient( # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 # Or passing parameters directly - client = AnthropicClient( + client = RawAnthropicClient( model_id="claude-sonnet-4-5-20250929", api_key="your_anthropic_api_key", ) # Or loading from a .env file - client = AnthropicClient(env_file_path="path/to/.env") + client = RawAnthropicClient(env_file_path="path/to/.env") # Or passing in an existing client from anthropic import AsyncAnthropic @@ -275,7 +282,7 @@ class AnthropicClient( anthropic_client = AsyncAnthropic( api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" ) - client = AnthropicClient( + client = RawAnthropicClient( model_id="claude-sonnet-4-5-20250929", anthropic_client=anthropic_client, ) @@ -289,7 +296,7 @@ class AnthropicClient( my_custom_option: str - client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + client: RawAnthropicClient[MyOptions] = RawAnthropicClient(model_id="claude-sonnet-4-5-20250929") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ @@ -320,8 +327,6 @@ class AnthropicClient( # Initialize parent super().__init__( additional_properties=additional_properties, - middleware=middleware, - function_invocation_configuration=function_invocation_configuration, ) # Initialize instance variables @@ -1376,3 +1381,95 @@ class AnthropicClient( The service URL for the chat client, or None if not set. """ return str(self.anthropic_client.base_url) + + +class AnthropicClient( + FunctionInvocationLayer[AnthropicOptionsT], + ChatMiddlewareLayer[AnthropicOptionsT], + ChatTelemetryLayer[AnthropicOptionsT], + RawAnthropicClient[AnthropicOptionsT], + Generic[AnthropicOptionsT], +): + """Anthropic chat client with middleware, telemetry, and function invocation support.""" + + def __init__( + self, + *, + api_key: str | None = None, + model_id: str | None = None, + anthropic_client: AsyncAnthropic | None = None, + additional_beta_flags: list[str] | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str | None = None, + ) -> None: + """Initialize an Anthropic client. + + Keyword Args: + api_key: The Anthropic API key to use for authentication. + model_id: The ID of the model to use. + anthropic_client: An existing Anthropic client to use. If not provided, one will be created. + This can be used to further configure the client before passing it in. + For instance if you need to set a different base_url for testing or private deployments. + additional_beta_flags: Additional beta flags to enable on the client. + Default flags are: "mcp-client-2025-04-04", "code-execution-2025-08-25". + additional_properties: Additional properties stored on the client instance. + middleware: Optional middleware to apply to the client. + function_invocation_configuration: Optional function invocation configuration override. + env_file_path: Path to environment file for loading settings. + env_file_encoding: Encoding of the environment file. + + Examples: + .. code-block:: python + + from agent_framework.anthropic import AnthropicClient + + # Using environment variables + # Set ANTHROPIC_API_KEY=your_anthropic_api_key + # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 + + # Or passing parameters directly + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + api_key="your_anthropic_api_key", + ) + + # Or loading from a .env file + client = AnthropicClient(env_file_path="path/to/.env") + + # Or passing in an existing client + from anthropic import AsyncAnthropic + + anthropic_client = AsyncAnthropic( + api_key="your_anthropic_api_key", base_url="https://custom-anthropic-endpoint.com" + ) + client = AnthropicClient( + model_id="claude-sonnet-4-5-20250929", + anthropic_client=anthropic_client, + ) + + # Using custom ChatOptions with type safety: + from typing import TypedDict + from agent_framework.anthropic import AnthropicChatOptions + + + class MyOptions(AnthropicChatOptions, total=False): + my_custom_option: str + + + client: AnthropicClient[MyOptions] = AnthropicClient(model_id="claude-sonnet-4-5-20250929") + response = await client.get_response("Hello", options={"my_custom_option": "value"}) + """ + super().__init__( + api_key=api_key, + model_id=model_id, + anthropic_client=anthropic_client, + additional_beta_flags=additional_beta_flags, + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 272239b1d7..258cc275ca 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -6,15 +6,18 @@ from unittest.mock import MagicMock, patch import pytest from agent_framework import ( + ChatMiddlewareLayer, ChatOptions, ChatResponseUpdate, Content, + FunctionInvocationLayer, Message, SupportsChatGetResponse, tool, ) from agent_framework._settings import load_settings from agent_framework._tools import SHELL_TOOL_KIND_VALUE +from agent_framework.observability import ChatTelemetryLayer from anthropic.types.beta import ( BetaMessage, BetaTextBlock, @@ -23,7 +26,7 @@ from anthropic.types.beta import ( ) from pydantic import BaseModel, Field -from agent_framework_anthropic import AnthropicClient +from agent_framework_anthropic import AnthropicClient, RawAnthropicClient from agent_framework_anthropic._chat_client import AnthropicSettings # Test constants @@ -64,6 +67,8 @@ def create_test_anthropic_client( client.additional_beta_flags = [] client.chat_middleware = [] client.function_middleware = [] + client._cached_chat_middleware_pipeline = None + client._cached_function_middleware_pipeline = None client.function_invocation_configuration = normalize_function_invocation_configuration(None) return client @@ -117,6 +122,19 @@ def test_anthropic_client_init_with_client(mock_anthropic_client: MagicMock) -> assert isinstance(client, SupportsChatGetResponse) +def test_anthropic_client_wraps_raw_client_with_standard_layer_order() -> None: + """Test AnthropicClient composes the standard public layer stack around the raw client.""" + assert issubclass(AnthropicClient, RawAnthropicClient) + mro = AnthropicClient.__mro__ + assert mro.index(FunctionInvocationLayer) < mro.index(ChatMiddlewareLayer) + assert mro.index(ChatMiddlewareLayer) < mro.index(ChatTelemetryLayer) + assert mro.index(ChatTelemetryLayer) < mro.index(RawAnthropicClient) + # RawAnthropicClient must not include the convenience layers + assert not issubclass(RawAnthropicClient, FunctionInvocationLayer) + assert not issubclass(RawAnthropicClient, ChatMiddlewareLayer) + assert not issubclass(RawAnthropicClient, ChatTelemetryLayer) + + def test_anthropic_client_init_auto_create_client( anthropic_unit_test_env: dict[str, str], ) -> None: diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index d349ef3247..63db1663d8 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -206,8 +206,8 @@ AzureAIAgentOptionsT = TypeVar( class AzureAIAgentClient( - ChatMiddlewareLayer[AzureAIAgentOptionsT], FunctionInvocationLayer[AzureAIAgentOptionsT], + ChatMiddlewareLayer[AzureAIAgentOptionsT], ChatTelemetryLayer[AzureAIAgentOptionsT], BaseChatClient[AzureAIAgentOptionsT], Generic[AzureAIAgentOptionsT], diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 1fc6c7c1c9..34ac6f29a5 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -97,9 +97,9 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ you should consider which additional layers to apply. There is a defined ordering that you should follow: - 1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware - 2. **FunctionInvocationLayer** - Handles tool/function calling loop - 3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry + 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware + 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry + 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry Use ``AzureAIClient`` instead for a fully-featured client with all layers applied. """ @@ -1214,8 +1214,8 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ class AzureAIClient( - ChatMiddlewareLayer[AzureAIClientOptionsT], FunctionInvocationLayer[AzureAIClientOptionsT], + ChatMiddlewareLayer[AzureAIClientOptionsT], ChatTelemetryLayer[AzureAIClientOptionsT], RawAzureAIClient[AzureAIClientOptionsT], Generic[AzureAIClientOptionsT], diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index afa073c6ab..65922e76b2 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -87,6 +87,8 @@ def create_test_azure_ai_chat_client( client.middleware = None client.chat_middleware = [] client.function_middleware = [] + client._cached_chat_middleware_pipeline = None + client._cached_function_middleware_pipeline = None client.otel_provider_name = "azure.ai" client.function_invocation_configuration = { "enabled": True, @@ -151,6 +153,10 @@ def test_azure_ai_chat_client_init_auto_create_client( chat_client.agent_name = None chat_client.additional_properties = {} chat_client.middleware = None + chat_client.chat_middleware = [] + chat_client.function_middleware = [] + chat_client._cached_chat_middleware_pipeline = None + chat_client._cached_function_middleware_pipeline = None assert chat_client.agents_client is mock_agents_client assert chat_client.agent_id is None diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index c546ef5535..0aefbe12f3 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -216,8 +216,8 @@ class BedrockSettings(TypedDict, total=False): class BedrockChatClient( - ChatMiddlewareLayer[BedrockChatOptionsT], FunctionInvocationLayer[BedrockChatOptionsT], + ChatMiddlewareLayer[BedrockChatOptionsT], ChatTelemetryLayer[BedrockChatOptionsT], BaseChatClient[BedrockChatOptionsT], Generic[BedrockChatOptionsT], diff --git a/python/packages/core/agent_framework/_clients.py b/python/packages/core/agent_framework/_clients.py index 4fd563d3e0..66740f5bf8 100644 --- a/python/packages/core/agent_framework/_clients.py +++ b/python/packages/core/agent_framework/_clients.py @@ -966,16 +966,7 @@ def _apply_get_response_docstrings() -> None: from .observability import ChatTelemetryLayer apply_layered_docstring(ChatTelemetryLayer.get_response, BaseChatClient.get_response) - apply_layered_docstring( - FunctionInvocationLayer.get_response, - ChatTelemetryLayer.get_response, - extra_keyword_args={ - "function_middleware": """ - Optional per-call function middleware. - When omitted, middleware configured on the client or forwarded from higher layers is used. - """, - }, - ) + apply_layered_docstring(FunctionInvocationLayer.get_response, ChatTelemetryLayer.get_response) apply_layered_docstring( ChatMiddlewareLayer.get_response, FunctionInvocationLayer.get_response, diff --git a/python/packages/core/agent_framework/_middleware.py b/python/packages/core/agent_framework/_middleware.py index 66845a2e9d..381482b91a 100644 --- a/python/packages/core/agent_framework/_middleware.py +++ b/python/packages/core/agent_framework/_middleware.py @@ -742,12 +742,17 @@ class AgentMiddlewarePipeline(BaseMiddlewarePipeline): middleware: The list of agent middleware to include in the pipeline. """ super().__init__() + self._source_middleware: tuple[AgentMiddlewareTypes, ...] = tuple(middleware) self._middleware: list[AgentMiddleware] = [] if middleware: for mdlware in middleware: self._register_middleware(mdlware) + def matches(self, middleware: Sequence[AgentMiddlewareTypes]) -> bool: + """Return whether this pipeline was built from the provided middleware sequence.""" + return self._source_middleware == tuple(middleware) + def _register_middleware(self, middleware: AgentMiddlewareTypes) -> None: """Register an agent middleware item. @@ -824,12 +829,17 @@ class FunctionMiddlewarePipeline(BaseMiddlewarePipeline): middleware: The list of function middleware to include in the pipeline. """ super().__init__() + self._source_middleware: tuple[FunctionMiddlewareTypes, ...] = tuple(middleware) self._middleware: list[FunctionMiddleware] = [] if middleware: for mdlware in middleware: self._register_middleware(mdlware) + def matches(self, middleware: Sequence[FunctionMiddlewareTypes]) -> bool: + """Return whether this pipeline was built from the provided middleware sequence.""" + return self._source_middleware == tuple(middleware) + def _register_middleware(self, middleware: FunctionMiddlewareTypes) -> None: """Register a function middleware item. @@ -892,12 +902,17 @@ class ChatMiddlewarePipeline(BaseMiddlewarePipeline): middleware: The list of chat middleware to include in the pipeline. """ super().__init__() + self._source_middleware: tuple[ChatMiddlewareTypes, ...] = tuple(middleware) self._middleware: list[ChatMiddleware] = [] if middleware: for mdlware in middleware: self._register_middleware(mdlware) + def matches(self, middleware: Sequence[ChatMiddlewareTypes]) -> bool: + """Return whether this pipeline was built from the provided middleware sequence.""" + return self._source_middleware == tuple(middleware) + def _register_middleware(self, middleware: ChatMiddlewareTypes) -> None: """Register a chat middleware item. @@ -980,16 +995,26 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): def __init__( self, *, - middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + middleware: Sequence[ChatMiddlewareTypes] | None = None, **kwargs: Any, ) -> None: - middleware_list = categorize_middleware(*(middleware or [])) - self.chat_middleware = middleware_list["chat"] - if "function_middleware" in kwargs and middleware_list["function"]: - raise ValueError("Cannot specify 'function_middleware' and 'middleware' at the same time.") - kwargs["function_middleware"] = middleware_list["function"] + self.chat_middleware = list(middleware) if middleware else [] + self._cached_chat_middleware_pipeline: ChatMiddlewarePipeline | None = None super().__init__(**kwargs) + def _get_chat_middleware_pipeline( + self, + middleware: Sequence[ChatMiddlewareTypes], + ) -> ChatMiddlewarePipeline: + effective_middleware = [*self.chat_middleware, *middleware] + if self._cached_chat_middleware_pipeline is not None and self._cached_chat_middleware_pipeline.matches( + effective_middleware + ): + return self._cached_chat_middleware_pipeline + + self._cached_chat_middleware_pipeline = ChatMiddlewarePipeline(*effective_middleware) + return self._cached_chat_middleware_pipeline + @overload def get_response( self, @@ -1052,14 +1077,8 @@ class ChatMiddlewareLayer(Generic[OptionsCoT]): kwargs["tokenizer"] = tokenizer effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - call_middleware = kwargs.pop("middleware", effective_client_kwargs.pop("middleware", [])) - middleware = categorize_middleware(call_middleware) - effective_client_kwargs["function_middleware"] = middleware["function"] - - pipeline = ChatMiddlewarePipeline( - *self.chat_middleware, - *middleware["chat"], - ) + call_middleware = effective_client_kwargs.pop("middleware", []) + pipeline = self._get_chat_middleware_pipeline(call_middleware) # type: ignore[reportUnknownArgumentType] if not pipeline.has_middlewares: return super_get_response( # type: ignore[no-any-return] messages=messages, @@ -1134,12 +1153,25 @@ class AgentMiddlewareLayer: ) -> None: middleware_list = categorize_middleware(middleware) self.agent_middleware = middleware_list["agent"] + self._cached_agent_middleware_pipeline: AgentMiddlewarePipeline | None = None # Pass middleware to super so BaseAgent can store it for dynamic rebuild super().__init__(*args, middleware=middleware, **kwargs) # type: ignore[call-arg] # Note: We intentionally don't extend client's middleware lists here. # Chat and function middleware is passed to the chat client at runtime via kwargs # in AgentMiddlewareLayer.run(), where it's properly combined with run-level middleware. + def _get_agent_middleware_pipeline( + self, + middleware: Sequence[AgentMiddlewareTypes], + ) -> AgentMiddlewarePipeline: + if self._cached_agent_middleware_pipeline is not None and self._cached_agent_middleware_pipeline.matches( + middleware + ): + return self._cached_agent_middleware_pipeline + + self._cached_agent_middleware_pipeline = AgentMiddlewarePipeline(*middleware) + return self._cached_agent_middleware_pipeline + @overload def run( self, @@ -1210,7 +1242,7 @@ class AgentMiddlewareLayer: ) base_middleware_list = categorize_middleware(base_middleware) run_middleware_list = categorize_middleware(middleware) - pipeline = AgentMiddlewarePipeline(*base_middleware_list["agent"], *run_middleware_list["agent"]) + pipeline = self._get_agent_middleware_pipeline([*base_middleware_list["agent"], *run_middleware_list["agent"]]) # Combine base and run-level function/chat middleware for forwarding to chat client combined_function_chat_middleware = ( @@ -1392,7 +1424,7 @@ def categorize_middleware( all_middleware: list[Any] = [] for source in middleware_sources: if source: - if isinstance(source, list): + if isinstance(source, Sequence) and not isinstance(source, (str, bytes)): all_middleware.extend(source) # type: ignore else: all_middleware.append(source) diff --git a/python/packages/core/agent_framework/_tools.py b/python/packages/core/agent_framework/_tools.py index c9810771de..cf7384588f 100644 --- a/python/packages/core/agent_framework/_tools.py +++ b/python/packages/core/agent_framework/_tools.py @@ -63,7 +63,12 @@ if TYPE_CHECKING: from ._clients import SupportsChatGetResponse from ._compaction import CompactionStrategy, TokenizerProtocol from ._mcp import MCPTool - from ._middleware import FunctionInvocationContext, FunctionMiddlewarePipeline, FunctionMiddlewareTypes + from ._middleware import ( + ChatAndFunctionMiddlewareTypes, + FunctionInvocationContext, + FunctionMiddlewarePipeline, + FunctionMiddlewareTypes, + ) from ._sessions import AgentSession from ._types import ( ChatOptions, @@ -2024,18 +2029,37 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): def __init__( self, *, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, function_invocation_configuration: FunctionInvocationConfiguration | None = None, **kwargs: Any, ) -> None: - self.function_middleware: list[FunctionMiddlewareTypes] = ( - list(function_middleware) if function_middleware else [] - ) + from ._middleware import categorize_middleware + + middleware_list = categorize_middleware(middleware) + self.function_middleware: list[FunctionMiddlewareTypes] = list(middleware_list["function"]) + self._cached_function_middleware_pipeline: FunctionMiddlewarePipeline | None = None self.function_invocation_configuration = normalize_function_invocation_configuration( function_invocation_configuration ) + if (chat_middleware := (middleware_list["chat"] or None)) is not None: + kwargs["middleware"] = chat_middleware super().__init__(**kwargs) + def _get_function_middleware_pipeline( + self, + middleware: Sequence[FunctionMiddlewareTypes], + ) -> FunctionMiddlewarePipeline: + from ._middleware import FunctionMiddlewarePipeline + + effective_middleware = [*self.function_middleware, *middleware] + if self._cached_function_middleware_pipeline is not None and self._cached_function_middleware_pipeline.matches( + effective_middleware + ): + return self._cached_function_middleware_pipeline + + self._cached_function_middleware_pipeline = FunctionMiddlewarePipeline(*effective_middleware) + return self._cached_function_middleware_pipeline + @overload def get_response( self, @@ -2043,6 +2067,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2057,6 +2082,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[False] = ..., options: OptionsCoT | ChatOptions[None] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2071,6 +2097,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: Literal[True], options: OptionsCoT | ChatOptions[Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, @@ -2084,14 +2111,14 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): *, stream: bool = False, options: OptionsCoT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, compaction_strategy: CompactionStrategy | None = None, tokenizer: TokenizerProtocol | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, **kwargs: Any, ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: - from ._middleware import FunctionMiddlewarePipeline + from ._middleware import categorize_middleware from ._types import ( ChatResponse, ChatResponseUpdate, @@ -2109,16 +2136,21 @@ class FunctionInvocationLayer(Generic[OptionsCoT]): ) effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} - effective_function_middleware = function_middleware - if effective_function_middleware is None: - middleware_from_client_kwargs = effective_client_kwargs.pop("function_middleware", None) - if middleware_from_client_kwargs is not None: - effective_function_middleware = cast(Sequence[Any], middleware_from_client_kwargs) + if middleware is not None: + existing = effective_client_kwargs.get("middleware", []) + effective_client_kwargs["middleware"] = [ + *( + existing + if isinstance(existing, Sequence) and not isinstance(existing, (str, bytes)) + else [existing] + ), + *middleware, + ] + runtime_middleware = categorize_middleware(effective_client_kwargs.pop("middleware", [])) - # ChatMiddleware adds this kwarg - function_middleware_pipeline = FunctionMiddlewarePipeline( - *(self.function_middleware), *(effective_function_middleware or []) - ) + function_middleware_pipeline = self._get_function_middleware_pipeline(runtime_middleware["function"]) + if runtime_middleware["chat"]: + effective_client_kwargs["middleware"] = runtime_middleware["chat"] max_errors = self.function_invocation_configuration.get( "max_consecutive_errors_per_request", DEFAULT_MAX_CONSECUTIVE_ERRORS_PER_REQUEST ) diff --git a/python/packages/core/agent_framework/_workflows/_viz.py b/python/packages/core/agent_framework/_workflows/_viz.py index 0fcf8af32d..54015b066c 100644 --- a/python/packages/core/agent_framework/_workflows/_viz.py +++ b/python/packages/core/agent_framework/_workflows/_viz.py @@ -109,7 +109,7 @@ class WorkflowViz: # Create a temporary graphviz Source object dot_content = self.to_digraph(include_internal_executors=include_internal_executors) - source = graphviz.Source(dot_content) + source = graphviz.Source(dot_content) # type: ignore[reportUnknownVariableType] try: if filename: @@ -131,7 +131,7 @@ class WorkflowViz: source.render(base_name, format=format, cleanup=True) # type: ignore return f"{base_name}.{format}" - except graphviz.backend.execute.ExecutableNotFound as e: + except graphviz.backend.execute.ExecutableNotFound as e: # type: ignore raise ImportError( "The graphviz executables are not found. The graphviz Python package is installed, but the " "graphviz executables (dot, neato, etc.) are not available on your system's PATH. " diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py index 2ae21d124c..ef598ebe21 100644 --- a/python/packages/core/agent_framework/azure/_chat_client.py +++ b/python/packages/core/agent_framework/azure/_chat_client.py @@ -152,8 +152,8 @@ AzureOpenAIChatClientT = TypeVar("AzureOpenAIChatClientT", bound="AzureOpenAICha class AzureOpenAIChatClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[AzureOpenAIChatOptionsT], FunctionInvocationLayer[AzureOpenAIChatOptionsT], + ChatMiddlewareLayer[AzureOpenAIChatOptionsT], ChatTelemetryLayer[AzureOpenAIChatOptionsT], RawOpenAIChatClient[AzureOpenAIChatOptionsT], Generic[AzureOpenAIChatOptionsT], diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py index 192576bd04..8387e49591 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -51,8 +51,8 @@ AzureOpenAIResponsesOptionsT = TypeVar( class AzureOpenAIResponsesClient( # type: ignore[misc] AzureOpenAIConfigMixin, - ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], FunctionInvocationLayer[AzureOpenAIResponsesOptionsT], + ChatMiddlewareLayer[AzureOpenAIResponsesOptionsT], ChatTelemetryLayer[AzureOpenAIResponsesOptionsT], RawOpenAIResponsesClient[AzureOpenAIResponsesOptionsT], Generic[AzureOpenAIResponsesOptionsT], diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index dcabaae8fc..a0cbd6a1a0 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -362,11 +362,15 @@ def _create_otlp_exporters( if protocol == "grpc": # Import all gRPC exporters try: - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as GRPCLogExporter - from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( - OTLPMetricExporter as GRPCMetricExporter, + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # type: ignore[reportMissingImports] + OTLPLogExporter as GRPCLogExporter, # type: ignore[reportUnknownVariableType] + ) + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # type: ignore[reportMissingImports] + OTLPMetricExporter as GRPCMetricExporter, # type: ignore[reportUnknownVariableType] + ) + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore[reportMissingImports] + OTLPSpanExporter as GRPCSpanExporter, # type: ignore[reportUnknownVariableType] ) - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GRPCSpanExporter except ImportError as exc: raise ImportError( "opentelemetry-exporter-otlp-proto-grpc is required for OTLP gRPC exporters. " @@ -375,21 +379,21 @@ def _create_otlp_exporters( if actual_logs_endpoint: exporters.append( - GRPCLogExporter( + GRPCLogExporter( # type: ignore[reportUnknownArgumentType] endpoint=actual_logs_endpoint, headers=actual_logs_headers if actual_logs_headers else None, ) ) if actual_traces_endpoint: exporters.append( - GRPCSpanExporter( + GRPCSpanExporter( # type: ignore[reportUnknownArgumentType] endpoint=actual_traces_endpoint, headers=actual_traces_headers if actual_traces_headers else None, ) ) if actual_metrics_endpoint: exporters.append( - GRPCMetricExporter( + GRPCMetricExporter( # type: ignore[reportUnknownArgumentType] endpoint=actual_metrics_endpoint, headers=actual_metrics_headers if actual_metrics_headers else None, ) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index b1d5e8795c..9179fb4a8c 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -210,8 +210,8 @@ OpenAIAssistantsOptionsT = TypeVar( class OpenAIAssistantsClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIAssistantsOptionsT], FunctionInvocationLayer[OpenAIAssistantsOptionsT], + ChatMiddlewareLayer[OpenAIAssistantsOptionsT], ChatTelemetryLayer[OpenAIAssistantsOptionsT], BaseChatClient[OpenAIAssistantsOptionsT], Generic[OpenAIAssistantsOptionsT], diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index bb7362a9bb..a77d44d933 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -31,7 +31,7 @@ from pydantic import BaseModel from .._clients import BaseChatClient from .._docstrings import apply_layered_docstring -from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, FunctionMiddlewareTypes +from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer from .._settings import load_settings from .._tools import ( FunctionInvocationConfiguration, @@ -156,9 +156,9 @@ class RawOpenAIChatClient( # type: ignore[misc] you should consider which additional layers to apply. There is a defined ordering that you should follow: - 1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware - 2. **FunctionInvocationLayer** - Handles tool/function calling loop - 3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry + 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware + 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry + 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry Use ``OpenAIChatClient`` instead for a fully-featured client with all layers applied. """ @@ -776,8 +776,8 @@ class RawOpenAIChatClient( # type: ignore[misc] class OpenAIChatClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIChatOptionsT], FunctionInvocationLayer[OpenAIChatOptionsT], + ChatMiddlewareLayer[OpenAIChatOptionsT], ChatTelemetryLayer[OpenAIChatOptionsT], RawOpenAIChatClient[OpenAIChatOptionsT], Generic[OpenAIChatOptionsT], @@ -791,7 +791,6 @@ class OpenAIChatClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: ChatOptions[ResponseModelBoundT], - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -805,7 +804,6 @@ class OpenAIChatClient( # type: ignore[misc] *, stream: Literal[False] = ..., options: OpenAIChatOptionsT | ChatOptions[None] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -819,7 +817,6 @@ class OpenAIChatClient( # type: ignore[misc] *, stream: Literal[True], options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -833,7 +830,6 @@ class OpenAIChatClient( # type: ignore[misc] *, stream: bool = False, options: OpenAIChatOptionsT | ChatOptions[Any] | None = None, - function_middleware: Sequence[FunctionMiddlewareTypes] | None = None, function_invocation_kwargs: Mapping[str, Any] | None = None, client_kwargs: Mapping[str, Any] | None = None, middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, @@ -844,14 +840,15 @@ class OpenAIChatClient( # type: ignore[misc] "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", super().get_response, # type: ignore[misc] ) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if middleware is not None: + effective_client_kwargs["middleware"] = middleware return super_get_response( # type: ignore[no-any-return] messages=messages, stream=stream, options=options, - function_middleware=function_middleware, function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - middleware=middleware, + client_kwargs=effective_client_kwargs, **kwargs, ) @@ -967,10 +964,6 @@ def _apply_openai_chat_client_docstrings() -> None: OpenAIChatClient.get_response, RawOpenAIChatClient.get_response, extra_keyword_args={ - "function_middleware": """ - Optional per-call function middleware. - When omitted, middleware configured on the client or forwarded from higher layers is used. - """, "middleware": """ Optional per-call chat and function middleware. This is merged with any middleware configured on the client for the current request. diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index 0769c3f1f9..0c57dffb39 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -249,9 +249,9 @@ class RawOpenAIResponsesClient( # type: ignore[misc] you should consider which additional layers to apply. There is a defined ordering that you should follow: - 1. **ChatMiddlewareLayer** - Should be applied first as it also prepares function middleware - 2. **FunctionInvocationLayer** - Handles tool/function calling loop - 3. **ChatTelemetryLayer** - Must be inside the function calling loop for correct per-call telemetry + 1. **FunctionInvocationLayer** - Owns the tool/function calling loop and routes function middleware + 2. **ChatMiddlewareLayer** - Applies chat middleware per model call and stays outside telemetry + 3. **ChatTelemetryLayer** - Must stay inside chat middleware for correct per-call telemetry Use ``OpenAIResponsesClient`` instead for a fully-featured client with all layers applied. """ @@ -2259,8 +2259,8 @@ class RawOpenAIResponsesClient( # type: ignore[misc] class OpenAIResponsesClient( # type: ignore[misc] OpenAIConfigMixin, - ChatMiddlewareLayer[OpenAIResponsesOptionsT], FunctionInvocationLayer[OpenAIResponsesOptionsT], + ChatMiddlewareLayer[OpenAIResponsesOptionsT], ChatTelemetryLayer[OpenAIResponsesOptionsT], RawOpenAIResponsesClient[OpenAIResponsesOptionsT], Generic[OpenAIResponsesOptionsT], diff --git a/python/packages/core/tests/core/conftest.py b/python/packages/core/tests/core/conftest.py index 2d1eec2d9a..57c0cf5217 100644 --- a/python/packages/core/tests/core/conftest.py +++ b/python/packages/core/tests/core/conftest.py @@ -128,8 +128,8 @@ class MockChatClient: class MockBaseChatClient( - ChatMiddlewareLayer[OptionsCoT], FunctionInvocationLayer[OptionsCoT], + ChatMiddlewareLayer[OptionsCoT], ChatTelemetryLayer[OptionsCoT], BaseChatClient[OptionsCoT], Generic[OptionsCoT], @@ -137,7 +137,7 @@ class MockBaseChatClient( """Mock implementation of a full-featured ChatClient.""" def __init__(self, **kwargs: Any): - super().__init__(function_middleware=[], **kwargs) + super().__init__(middleware=[], **kwargs) self.run_responses: list[ChatResponse] = [] self.streaming_responses: list[list[ChatResponseUpdate]] = [] self.call_count: int = 0 diff --git a/python/packages/core/tests/core/test_clients.py b/python/packages/core/tests/core/test_clients.py index 7e150c47c6..258a31d73b 100644 --- a/python/packages/core/tests/core/test_clients.py +++ b/python/packages/core/tests/core/test_clients.py @@ -74,8 +74,8 @@ def test_openai_chat_client_get_response_docstring_surfaces_layered_runtime_docs assert docstring is not None assert "Get a response from a chat client." in docstring assert "function_invocation_kwargs" in docstring - assert "function_middleware: Optional per-call function middleware." in docstring assert "middleware: Optional per-call chat and function middleware." in docstring + assert "function_middleware: Optional per-call function middleware." not in docstring def test_openai_chat_client_get_response_is_defined_on_openai_class() -> None: @@ -84,7 +84,6 @@ def test_openai_chat_client_get_response_is_defined_on_openai_class() -> None: signature = inspect.signature(OpenAIChatClient.get_response) assert OpenAIChatClient.get_response.__qualname__ == "OpenAIChatClient.get_response" - assert "function_middleware" in signature.parameters assert "middleware" in signature.parameters diff --git a/python/packages/core/tests/core/test_function_invocation_logic.py b/python/packages/core/tests/core/test_function_invocation_logic.py index 3c61040289..d9659837a8 100644 --- a/python/packages/core/tests/core/test_function_invocation_logic.py +++ b/python/packages/core/tests/core/test_function_invocation_logic.py @@ -3226,7 +3226,7 @@ async def test_terminate_loop_single_function_call(chat_client_base: SupportsCha response = await chat_client_base.get_response( "hello", options={"tool_choice": "auto", "tools": [ai_func]}, - middleware=[TerminateLoopMiddleware()], + client_kwargs={"middleware": [TerminateLoopMiddleware()]}, ) # Function should NOT have been executed - middleware intercepted it @@ -3292,7 +3292,7 @@ async def test_terminate_loop_multiple_function_calls_one_terminates(chat_client response = await chat_client_base.get_response( "hello", options={"tool_choice": "auto", "tools": [normal_func, terminating_func]}, - middleware=[SelectiveTerminateMiddleware()], + client_kwargs={"middleware": [SelectiveTerminateMiddleware()]}, ) # normal_function should have executed (middleware calls next_handler) @@ -3345,7 +3345,7 @@ async def test_terminate_loop_streaming_single_function_call(chat_client_base: S async for update in chat_client_base.get_response( "hello", options={"tool_choice": "auto", "tools": [ai_func]}, - middleware=[TerminateLoopMiddleware()], + client_kwargs={"middleware": [TerminateLoopMiddleware()]}, stream=True, ): updates.append(update) @@ -3389,12 +3389,12 @@ async def test_conversation_id_updated_in_options_between_tool_iterations(): conversation_ids_received: list[str | None] = [] class TrackingChatClient( - ChatMiddlewareLayer, FunctionInvocationLayer, + ChatMiddlewareLayer, BaseChatClient, ): def __init__(self) -> None: - super().__init__(function_middleware=[]) + super().__init__(middleware=[]) self.run_responses: list[ChatResponse] = [] self.streaming_responses: list[list[ChatResponseUpdate]] = [] self.call_count: int = 0 diff --git a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py index 160ea0fcc4..11a738a0b9 100644 --- a/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py +++ b/python/packages/core/tests/core/test_kwargs_propagation_to_ai_function.py @@ -84,8 +84,8 @@ class _MockBaseChatClient(BaseChatClient[Any]): class FunctionInvokingMockClient( - ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], + ChatMiddlewareLayer[Any], ChatTelemetryLayer[Any], _MockBaseChatClient, ): diff --git a/python/packages/core/tests/core/test_middleware.py b/python/packages/core/tests/core/test_middleware.py index 6c559c40d4..0026cbf98f 100644 --- a/python/packages/core/tests/core/test_middleware.py +++ b/python/packages/core/tests/core/test_middleware.py @@ -28,6 +28,7 @@ from agent_framework._middleware import ( FunctionMiddleware, FunctionMiddlewarePipeline, MiddlewareTermination, + categorize_middleware, ) from agent_framework._tools import FunctionTool @@ -1681,3 +1682,49 @@ def mock_chat_client() -> Any: client = MagicMock(spec=SupportsChatGetResponse) client.service_url = MagicMock(return_value="mock://test") return client + + +class TestCategorizeMiddleware: + """Test cases for categorize_middleware.""" + + def test_categorize_middleware_with_tuple(self) -> None: + """Test that tuple middleware sources are unpacked, not appended as a single item.""" + chat_mw = TestChatMiddleware() + function_mw = TestFunctionMiddleware() + agent_mw = TestAgentMiddleware() + result = categorize_middleware((chat_mw, function_mw, agent_mw)) + assert result["chat"] == [chat_mw] + assert result["function"] == [function_mw] + assert result["agent"] == [agent_mw] + + def test_categorize_middleware_with_list(self) -> None: + """Test that list middleware sources are unpacked correctly.""" + chat_mw = TestChatMiddleware() + function_mw = TestFunctionMiddleware() + result = categorize_middleware([chat_mw, function_mw]) + assert result["chat"] == [chat_mw] + assert result["function"] == [function_mw] + assert result["agent"] == [] + + def test_categorize_middleware_with_none(self) -> None: + """Test that None middleware sources are handled.""" + result = categorize_middleware(None) + assert result["chat"] == [] + assert result["function"] == [] + assert result["agent"] == [] + + def test_categorize_middleware_with_single_item(self) -> None: + """Test that a single unwrapped middleware item is appended correctly.""" + chat_mw = TestChatMiddleware() + result = categorize_middleware(chat_mw) + assert result["chat"] == [chat_mw] + assert result["function"] == [] + assert result["agent"] == [] + + def test_categorize_middleware_with_string_does_not_decompose(self) -> None: + """Test that a string is not decomposed character-by-character.""" + result = categorize_middleware("not_a_middleware") + # String should be treated as a single item, not decomposed into characters + total_items = len(result["chat"]) + len(result["function"]) + len(result["agent"]) + assert total_items == 1 + assert result["agent"] == ["not_a_middleware"] diff --git a/python/packages/core/tests/core/test_middleware_with_agent.py b/python/packages/core/tests/core/test_middleware_with_agent.py index bfe5ec1293..6470a8202e 100644 --- a/python/packages/core/tests/core/test_middleware_with_agent.py +++ b/python/packages/core/tests/core/test_middleware_with_agent.py @@ -697,6 +697,26 @@ class TestChatAgentFunctionMiddlewareWithTools: assert function_calls[0].name == "sample_tool_function" assert function_results[0].call_id == function_calls[0].call_id + def test_agent_middleware_pipeline_cache_reuses_matching_middleware(self) -> None: + """Test that identical agent middleware sets reuse the cached pipeline.""" + + @agent_middleware + async def first_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @agent_middleware + async def second_middleware(context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + agent = Agent(client=MockBaseChatClient()) + + first_pipeline = agent._get_agent_middleware_pipeline([first_middleware]) + second_pipeline = agent._get_agent_middleware_pipeline([first_middleware]) + third_pipeline = agent._get_agent_middleware_pipeline([second_middleware]) + + assert first_pipeline is second_pipeline + assert third_pipeline is not first_pipeline + async def test_function_middleware_can_access_and_override_custom_kwargs( self, chat_client_base: "MockBaseChatClient" ) -> None: @@ -1969,6 +1989,77 @@ class TestChatAgentChatMiddleware: "agent_middleware_after", ] + async def test_combined_middleware_with_tool_loop(self) -> None: + """Test Agent middleware ordering when tool calls trigger multiple chat rounds.""" + execution_order: list[str] = [] + chat_round = 0 + client = MockBaseChatClient() + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_123", + name="sample_tool_function", + arguments='{"location": "Seattle"}', + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Final response")]), + ] + + async def tracking_agent_middleware( + context: AgentContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + execution_order.append("agent_middleware_before") + await call_next() + execution_order.append("agent_middleware_after") + + async def tracking_chat_middleware( + context: ChatContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + nonlocal chat_round + chat_round += 1 + execution_order.append(f"chat_middleware_before_{chat_round}") + await call_next() + execution_order.append(f"chat_middleware_after_{chat_round}") + + async def tracking_function_middleware( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + execution_order.append("function_middleware_before") + await call_next() + execution_order.append("function_middleware_after") + + agent = Agent( + client=client, + middleware=[tracking_chat_middleware, tracking_function_middleware, tracking_agent_middleware], + tools=[sample_tool_function], + ) + + response = await agent.run([Message(role="user", text="test")]) + + assert response is not None + assert client.call_count == 2 + assert response.messages[-1].text == "Final response" + assert execution_order == [ + "agent_middleware_before", + "chat_middleware_before_1", + "chat_middleware_after_1", + "function_middleware_before", + "function_middleware_after", + "chat_middleware_before_2", + "chat_middleware_after_2", + "agent_middleware_after", + ] + async def test_agent_middleware_can_access_and_override_custom_kwargs(self) -> None: """Test that agent middleware can access and override custom parameters like temperature.""" captured_kwargs: dict[str, Any] = {} diff --git a/python/packages/core/tests/core/test_middleware_with_chat.py b/python/packages/core/tests/core/test_middleware_with_chat.py index 62a168ccb0..5fa9d64031 100644 --- a/python/packages/core/tests/core/test_middleware_with_chat.py +++ b/python/packages/core/tests/core/test_middleware_with_chat.py @@ -274,7 +274,10 @@ class TestChatMiddleware: # First call with run-level middleware messages = [Message(role="user", text="first message")] - response1 = await chat_client_base.get_response(messages, middleware=[counting_middleware]) + response1 = await chat_client_base.get_response( + messages, + client_kwargs={"middleware": [counting_middleware]}, + ) assert response1 is not None assert execution_count["count"] == 1 @@ -286,7 +289,10 @@ class TestChatMiddleware: # Third call with run-level middleware again - should execute messages = [Message(role="user", text="third message")] - response3 = await chat_client_base.get_response(messages, middleware=[counting_middleware]) + response3 = await chat_client_base.get_response( + messages, + client_kwargs={"middleware": [counting_middleware]}, + ) assert response3 is not None assert execution_count["count"] == 2 # Should be 2 now @@ -335,6 +341,81 @@ class TestChatMiddleware: assert modified_kwargs["new_param"] == "added_by_middleware" assert modified_kwargs["custom_param"] == "test_value" # Should still be there + def test_chat_middleware_pipeline_cache_reuses_matching_middleware( + self, + chat_client_base: "MockBaseChatClient", + ) -> None: + """Test that identical chat middleware sets reuse the cached pipeline.""" + + @chat_middleware + async def first_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def second_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + first_pipeline = chat_client_base._get_chat_middleware_pipeline([first_middleware]) + second_pipeline = chat_client_base._get_chat_middleware_pipeline([first_middleware]) + third_pipeline = chat_client_base._get_chat_middleware_pipeline([second_middleware]) + + assert first_pipeline is second_pipeline + assert third_pipeline is not first_pipeline + + def test_chat_middleware_pipeline_cache_includes_base_middleware( + self, + chat_client_base: "MockBaseChatClient", + ) -> None: + """Test that chat middleware cache key includes base middleware to prevent incorrect reuse.""" + + @chat_middleware + async def base_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @chat_middleware + async def runtime_middleware(context: ChatContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + # Without base middleware + pipeline_no_base = chat_client_base._get_chat_middleware_pipeline([runtime_middleware]) + + # With base middleware + chat_client_base.chat_middleware = [base_middleware] + pipeline_with_base = chat_client_base._get_chat_middleware_pipeline([runtime_middleware]) + + assert pipeline_with_base is not pipeline_no_base + + def test_function_middleware_pipeline_cache_reuses_matching_middleware( + self, + chat_client_base: "MockBaseChatClient", + ) -> None: + """Test that identical function middleware sets reuse the cached pipeline.""" + + @function_middleware + async def base_middleware(context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]]) -> None: + await call_next() + + @function_middleware + async def first_runtime_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + await call_next() + + @function_middleware + async def second_runtime_middleware( + context: FunctionInvocationContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + await call_next() + + chat_client_base.function_middleware = [base_middleware] + + first_pipeline = chat_client_base._get_function_middleware_pipeline([first_runtime_middleware]) + second_pipeline = chat_client_base._get_function_middleware_pipeline([first_runtime_middleware]) + third_pipeline = chat_client_base._get_function_middleware_pipeline([second_runtime_middleware]) + + assert first_pipeline is second_pipeline + assert third_pipeline is not first_pipeline + async def test_function_middleware_registration_on_chat_client( self, chat_client_base: "MockBaseChatClient" ) -> None: @@ -450,7 +531,9 @@ class TestChatMiddleware: # Execute the chat client directly with run-level middleware and tools messages = [Message(role="user", text="What's the weather in New York?")] response = await client.get_response( - messages, options={"tools": [sample_tool_wrapped]}, middleware=[run_level_function_middleware] + messages, + options={"tools": [sample_tool_wrapped]}, + client_kwargs={"middleware": [run_level_function_middleware]}, ) # Verify response @@ -463,3 +546,156 @@ class TestChatMiddleware: "run_level_function_middleware_before", "run_level_function_middleware_after", ] + + async def test_run_level_chat_and_function_middleware_split_per_function_loop_round(self) -> None: + """Test mixed run-level middleware is split so chat middleware runs per model call.""" + execution_order: list[str] = [] + chat_round = 0 + + @chat_middleware + async def run_level_chat_middleware( + context: ChatContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + nonlocal chat_round + chat_round += 1 + execution_order.append(f"chat_middleware_before_{chat_round}") + await call_next() + execution_order.append(f"chat_middleware_after_{chat_round}") + + @function_middleware + async def run_level_function_middleware( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + execution_order.append("function_middleware_before") + await call_next() + execution_order.append("function_middleware_after") + + def sample_tool(location: str) -> str: + """Get weather for a location.""" + return f"Weather in {location}: sunny" + + sample_tool_wrapped = FunctionTool( + func=sample_tool, + name="sample_tool", + description="Get weather for a location", + approval_mode="never_require", + ) + + client = MockBaseChatClient() + client.run_responses = [ + ChatResponse( + messages=[ + Message( + role="assistant", + contents=[ + Content.from_function_call( + call_id="call_3", + name="sample_tool", + arguments={"location": "Seattle"}, + ) + ], + ) + ] + ), + ChatResponse(messages=[Message(role="assistant", text="Based on the weather data, it's sunny!")]), + ] + + response = await client.get_response( + [Message(role="user", text="What's the weather in Seattle?")], + options={"tools": [sample_tool_wrapped]}, + client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]}, + ) + + assert response is not None + assert client.call_count == 2 + assert response.messages[-1].text == "Based on the weather data, it's sunny!" + assert execution_order == [ + "chat_middleware_before_1", + "chat_middleware_after_1", + "function_middleware_before", + "function_middleware_after", + "chat_middleware_before_2", + "chat_middleware_after_2", + ] + + async def test_run_level_chat_and_function_middleware_split_per_function_loop_round_streaming(self) -> None: + """Test mixed run-level middleware is split so chat middleware runs per model call in streaming mode.""" + execution_order: list[str] = [] + chat_round = 0 + + @chat_middleware + async def run_level_chat_middleware( + context: ChatContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + nonlocal chat_round + chat_round += 1 + execution_order.append(f"chat_middleware_before_{chat_round}") + await call_next() + execution_order.append(f"chat_middleware_after_{chat_round}") + + @function_middleware + async def run_level_function_middleware( + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + execution_order.append("function_middleware_before") + await call_next() + execution_order.append("function_middleware_after") + + def sample_tool(location: str) -> str: + """Get weather for a location.""" + return f"Weather in {location}: sunny" + + sample_tool_wrapped = FunctionTool( + func=sample_tool, + name="sample_tool", + description="Get weather for a location", + approval_mode="never_require", + ) + + client = MockBaseChatClient() + client.streaming_responses = [ + [ + ChatResponseUpdate( + contents=[ + Content.from_function_call( + call_id="call_3", + name="sample_tool", + arguments='{"location": "Seattle"}', + ) + ], + role="assistant", + finish_reason="tool_calls", + ), + ], + [ + ChatResponseUpdate( + contents=[Content.from_text("Based on the weather data, it's sunny!")], + role="assistant", + finish_reason="stop", + ), + ], + ] + + updates: list[ChatResponseUpdate] = [] + async for update in client.get_response( + [Message(role="user", text="What's the weather in Seattle?")], + options={"tools": [sample_tool_wrapped]}, + client_kwargs={"middleware": [run_level_chat_middleware, run_level_function_middleware]}, + stream=True, + ): + updates.append(update) + + assert client.call_count == 2 + assert len(updates) > 0 + assert execution_order == [ + "chat_middleware_before_1", + "chat_middleware_after_1", + "function_middleware_before", + "function_middleware_after", + "chat_middleware_before_2", + "chat_middleware_after_2", + ] diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 367e32bf92..7982985b94 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -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 (ChatMiddlewareLayer, FunctionInvocationLayer, + When using the correct layer ordering (FunctionInvocationLayer, ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient), the spans should appear in this order: 1. First 'chat' span (initial LLM call that returns function call) 2. 'execute_tool' span (function invocation) @@ -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 ChatTelemetryLayer - # This ensures each inner LLM call gets its own telemetry span + # Correct layer ordering: FunctionInvocationLayer BEFORE ChatMiddlewareLayer BEFORE ChatTelemetryLayer + # This ensures each inner LLM call traverses chat middleware and still gets its own telemetry span class MockChatClientWithLayers( - ChatMiddlewareLayer, FunctionInvocationLayer, + ChatMiddlewareLayer, ChatTelemetryLayer, BaseChatClient, ): diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 4c1e64cd7c..2566d031aa 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -130,8 +130,8 @@ class FoundryLocalSettings(TypedDict, total=False): class FoundryLocalClient( - ChatMiddlewareLayer[FoundryLocalChatOptionsT], FunctionInvocationLayer[FoundryLocalChatOptionsT], + ChatMiddlewareLayer[FoundryLocalChatOptionsT], ChatTelemetryLayer[FoundryLocalChatOptionsT], RawOpenAIChatClient[FoundryLocalChatOptionsT], Generic[FoundryLocalChatOptionsT], diff --git a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py index 07b1945882..266ae8a107 100644 --- a/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py +++ b/python/packages/lab/gaia/agent_framework_lab_gaia/gaia.py @@ -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 + import pyarrow.parquet as pq # type: ignore[reportMissingImports] pq_any = cast(Any, pq) table: Any = pq_any.read_table(p) diff --git a/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py b/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py index 9526498cc2..3da1121910 100644 --- a/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py +++ b/python/packages/lab/lightning/agent_framework_lab_lightning/__init__.py @@ -7,8 +7,8 @@ from __future__ import annotations import importlib.metadata from agent_framework.observability import enable_instrumentation -from agentlightning.tracer import ( - AgentOpsTracer, # pyright: ignore[reportMissingImports] # type: ignore[import-not-found] +from agentlightning.tracer import ( # type: ignore[reportMissingImports] + AgentOpsTracer, # type: ignore[reportMissingImports, import-not-found] ) try: diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index b931c89499..0c7f232797 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -285,8 +285,8 @@ logger = logging.getLogger("agent_framework.ollama") class OllamaChatClient( - ChatMiddlewareLayer[OllamaChatOptionsT], FunctionInvocationLayer[OllamaChatOptionsT], + ChatMiddlewareLayer[OllamaChatOptionsT], ChatTelemetryLayer[OllamaChatOptionsT], BaseChatClient[OllamaChatOptionsT], ): diff --git a/python/packages/orchestrations/tests/test_handoff.py b/python/packages/orchestrations/tests/test_handoff.py index 43c2f9153a..5c594ed537 100644 --- a/python/packages/orchestrations/tests/test_handoff.py +++ b/python/packages/orchestrations/tests/test_handoff.py @@ -33,7 +33,7 @@ from agent_framework_orchestrations._handoff import ( from agent_framework_orchestrations._orchestrator_helpers import clean_conversation_for_handoff -class MockChatClient(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): +class MockChatClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): +class ContextAwareRefundClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class ApprovalReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class StrictStatelessApprovalClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class ReplaySafeHandoffClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class RefundReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class OrderReplayClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[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(ChatMiddlewareLayer[Any], FunctionInvocationLayer[Any], BaseChatClient[Any]): + class FinalizingClient(FunctionInvocationLayer[Any], ChatMiddlewareLayer[Any], BaseChatClient[Any]): def __init__(self) -> None: ChatMiddlewareLayer.__init__(self) FunctionInvocationLayer.__init__(self) diff --git a/python/samples/02-agents/auto_retry.py b/python/samples/02-agents/auto_retry.py index 7c985bd0c1..0a5169ad3d 100644 --- a/python/samples/02-agents/auto_retry.py +++ b/python/samples/02-agents/auto_retry.py @@ -114,10 +114,11 @@ class RetryingAzureOpenAIChatClient(AzureOpenAIChatClient): class RateLimitRetryMiddleware(ChatMiddleware): - """Chat middleware that retries the full request pipeline on rate limit errors. + """Chat middleware that retries a single model-call pipeline on rate limit errors. Register this middleware on an agent (or at the run level) to automatically - retry any call_next() invocation that raises RateLimitError. + retry any chat-model call that raises RateLimitError. In tool-loop scenarios, + the middleware applies independently to each inner model call. """ def __init__(self, *, max_attempts: int = RETRY_ATTEMPTS) -> None: @@ -154,8 +155,9 @@ 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 model inference triggers an automatic retry with exponential - back-off. + 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. """ @retry( diff --git a/python/samples/02-agents/chat_client/custom_chat_client.py b/python/samples/02-agents/chat_client/custom_chat_client.py index 5adcf50d15..7a9aaa95f6 100644 --- a/python/samples/02-agents/chat_client/custom_chat_client.py +++ b/python/samples/02-agents/chat_client/custom_chat_client.py @@ -29,7 +29,10 @@ else: Custom Chat Client Implementation Example This sample demonstrates implementing a custom chat client and optionally composing -middleware, telemetry, and function invocation layers explicitly. +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. """ @@ -124,9 +127,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.""" diff --git a/python/samples/02-agents/middleware/README.md b/python/samples/02-agents/middleware/README.md new file mode 100644 index 0000000000..754f96e815 --- /dev/null +++ b/python/samples/02-agents/middleware/README.md @@ -0,0 +1,37 @@ +# 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. diff --git a/python/samples/02-agents/middleware/agent_and_run_level_middleware.py b/python/samples/02-agents/middleware/agent_and_run_level_middleware.py index 55ccce3507..158d90daee 100644 --- a/python/samples/02-agents/middleware/agent_and_run_level_middleware.py +++ b/python/samples/02-agents/middleware/agent_and_run_level_middleware.py @@ -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 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. + 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. """ diff --git a/python/samples/02-agents/middleware/usage_tracking_middleware.py b/python/samples/02-agents/middleware/usage_tracking_middleware.py new file mode 100644 index 0000000000..877d2a8a82 --- /dev/null +++ b/python/samples/02-agents/middleware/usage_tracking_middleware.py @@ -0,0 +1,185 @@ +# 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, ... +""" diff --git a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py index 7afd359264..af7fcc6287 100644 --- a/python/samples/02-agents/observability/advanced_manual_setup_console_output.py +++ b/python/samples/02-agents/observability/advanced_manual_setup_console_output.py @@ -96,10 +96,16 @@ async def run_chat_client() -> None: stream: Whether to use streaming for the plugin Remarks: - 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. + 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. So for the scenario below, you should see the following: diff --git a/python/samples/02-agents/observability/advanced_zero_code.py b/python/samples/02-agents/observability/advanced_zero_code.py index 477a5b4d9b..981b14a0e6 100644 --- a/python/samples/02-agents/observability/advanced_zero_code.py +++ b/python/samples/02-agents/observability/advanced_zero_code.py @@ -71,10 +71,12 @@ async def run_chat_client(client: "SupportsChatGetResponse", stream: bool = Fals stream: Whether to use streaming for the plugin Remarks: - 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. + 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. So for the scenario below, you should see the following: diff --git a/python/samples/02-agents/providers/custom/README.md b/python/samples/02-agents/providers/custom/README.md index f2d67e0315..ac58a77e69 100644 --- a/python/samples/02-agents/providers/custom/README.md +++ b/python/samples/02-agents/providers/custom/README.md @@ -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. **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 +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 4. **Raw...Client** - The base implementation (e.g., `RawOpenAIChatClient`) Example of correct layer composition: ```python class MyCustomClient( - ChatMiddlewareLayer[TOptions], FunctionInvocationLayer[TOptions], + ChatMiddlewareLayer[TOptions], ChatTelemetryLayer[TOptions], RawOpenAIChatClient[TOptions], # or BaseChatClient for custom implementations Generic[TOptions], diff --git a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py index 1faad6a2e9..4c60902dc2 100644 --- a/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py +++ b/python/samples/05-end-to-end/hosted_agents/agent_with_local_tools/main.py @@ -16,7 +16,6 @@ from azure.ai.agentserver.agentframework import from_agent_framework from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential from dotenv import load_dotenv - load_dotenv(override=True) # Configure these for your Foundry project From b4c4f5094e6c8c1a4097d0ca07a8cd46094a1b51 Mon Sep 17 00:00:00 2001 From: James Sturtevant Date: Thu, 19 Mar 2026 18:27:36 -0700 Subject: [PATCH 08/19] Python: Emit tool call events in GitHubCopilotAgent streaming (#4711) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Emit tool call events in GitHubCopilotAgent streaming _stream_updates now yields FunctionCallContent for TOOL_EXECUTION_START and FunctionResultContent for TOOL_EXECUTION_COMPLETE events from the Copilot SDK session. This enables DevUI and other consumers to display tool calls during streaming agent execution. Previously only ASSISTANT_MESSAGE_DELTA, SESSION_IDLE, and SESSION_ERROR were handled — tool execution events were silently dropped. Signed-off-by: James Sturtevant * Add some tests Signed-off-by: James Sturtevant * Respond to feedback Signed-off-by: James Sturtevant * Fix TOOL_EXECUTION_COMPLETE to use correct SDK types - Read result text from session_events.Result.content (not ToolResult.text_result_for_llm) - Read failure state from event.data.success/error (not result_obj.result_type/error) - Handle ErrorClass.message and plain string errors - Update tests to use session_events.Result and ErrorClass - Add tests for string errors, success-with-error, and COMPLETE missing fields Signed-off-by: James Sturtevant --------- Signed-off-by: James Sturtevant --- .../agent_framework_github_copilot/_agent.py | 37 ++ .../tests/test_github_copilot_agent.py | 372 +++++++++++++++++- 2 files changed, 408 insertions(+), 1 deletion(-) diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 4f71c9d3b5..69f3bf20d4 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -458,6 +458,43 @@ 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: diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index dd2c259b7d..d3aae50bba 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -20,7 +20,7 @@ from agent_framework import ( Message, ) from agent_framework.exceptions import AgentException -from copilot.generated.session_events import Data, SessionEvent, SessionEventType +from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType from copilot.types import ToolInvocation, ToolResult from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions @@ -463,6 +463,376 @@ 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.""" From 26cd5cc1bf99a864576392fca668c176c8cef89e Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:35:47 +0900 Subject: [PATCH 09/19] Python: Bump Python version to 1.0.0rc5 and 1.0.0b260319 for a release. (#4807) * Bump Python version to 1.0.0rc5 and 1.0.0b260319 for a release. * update missed pkg versions * Update changelog --- python/CHANGELOG.md | 52 ++++++++++++++++++- python/packages/a2a/pyproject.toml | 4 +- python/packages/ag-ui/pyproject.toml | 4 +- python/packages/anthropic/pyproject.toml | 4 +- .../packages/azure-ai-search/pyproject.toml | 4 +- python/packages/azure-ai/pyproject.toml | 4 +- python/packages/azure-cosmos/pyproject.toml | 4 +- python/packages/azurefunctions/pyproject.toml | 4 +- python/packages/bedrock/pyproject.toml | 4 +- python/packages/chatkit/pyproject.toml | 4 +- python/packages/claude/pyproject.toml | 4 +- python/packages/copilotstudio/pyproject.toml | 4 +- python/packages/core/pyproject.toml | 2 +- python/packages/declarative/pyproject.toml | 4 +- python/packages/devui/pyproject.toml | 4 +- python/packages/durabletask/pyproject.toml | 4 +- python/packages/foundry_local/pyproject.toml | 4 +- python/packages/github_copilot/pyproject.toml | 4 +- python/packages/lab/pyproject.toml | 4 +- python/packages/mem0/pyproject.toml | 4 +- python/packages/ollama/pyproject.toml | 4 +- python/packages/orchestrations/pyproject.toml | 4 +- python/packages/purview/pyproject.toml | 4 +- python/packages/redis/pyproject.toml | 4 +- python/pyproject.toml | 4 +- python/uv.lock | 48 ++++++++--------- 26 files changed, 122 insertions(+), 72 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 9bdb542519..609c59c078 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0rc5] - 2026-03-19 + +### Added + +- **samples**: Add foundry hosted agents samples for python ([#4648](https://github.com/microsoft/agent-framework/pull/4648)) +- **repo**: Add automated stale issue and PR follow-up ping workflow ([#4776](https://github.com/microsoft/agent-framework/pull/4776)) +- **agent-framework-ag-ui**: Emit AG-UI events for MCP tool calls, results, and text reasoning ([#4760](https://github.com/microsoft/agent-framework/pull/4760)) +- **agent-framework-ag-ui**: Emit TOOL_CALL_RESULT events when resuming after tool approval ([#4758](https://github.com/microsoft/agent-framework/pull/4758)) + +### Changed + +- **agent-framework-devui**: Bump minimatch from 3.1.2 to 3.1.5 in frontend ([#4337](https://github.com/microsoft/agent-framework/pull/4337)) +- **agent-framework-devui**: Bump rollup from 4.47.1 to 4.59.0 in frontend ([#4338](https://github.com/microsoft/agent-framework/pull/4338)) +- **agent-framework-core**: Unify tool results as `Content` items with rich content support ([#4331](https://github.com/microsoft/agent-framework/pull/4331)) +- **agent-framework-a2a**: Default `A2AAgent` name and description from `AgentCard` ([#4661](https://github.com/microsoft/agent-framework/pull/4661)) +- **agent-framework-core**: [BREAKING] Clean up kwargs across agents, chat clients, tools, and sessions ([#4581](https://github.com/microsoft/agent-framework/pull/4581)) +- **agent-framework-devui**: Bump tar from 7.5.9 to 7.5.11 ([#4688](https://github.com/microsoft/agent-framework/pull/4688)) +- **repo**: Improve Python dependency range automation ([#4343](https://github.com/microsoft/agent-framework/pull/4343)) +- **agent-framework-core**: Normalize empty MCP tool output to `null` ([#4683](https://github.com/microsoft/agent-framework/pull/4683)) +- **agent-framework-core**: Remove bad dependency ([#4696](https://github.com/microsoft/agent-framework/pull/4696)) +- **agent-framework-core**: Keep MCP cleanup on the owner task ([#4687](https://github.com/microsoft/agent-framework/pull/4687)) +- **agent-framework-a2a**: Preserve A2A message `context_id` ([#4686](https://github.com/microsoft/agent-framework/pull/4686)) +- **repo**: Bump `danielpalme/ReportGenerator-GitHub-Action` from 5.5.1 to 5.5.3 ([#4542](https://github.com/microsoft/agent-framework/pull/4542)) +- **repo**: Bump `MishaKav/pytest-coverage-comment` from 1.2.0 to 1.6.0 ([#4543](https://github.com/microsoft/agent-framework/pull/4543)) +- **agent-framework-core**: Bump `pyjwt` from 2.11.0 to 2.12.0 ([#4699](https://github.com/microsoft/agent-framework/pull/4699)) +- **agent-framework-azure-ai**: Reduce Azure chat client import overhead ([#4744](https://github.com/microsoft/agent-framework/pull/4744)) +- **repo**: Simplify Python Poe tasks and unify package selectors ([#4722](https://github.com/microsoft/agent-framework/pull/4722)) +- **agent-framework-core**: Aggregate token usage across tool-call loop iterations in `invoke_agent` span ([#4739](https://github.com/microsoft/agent-framework/pull/4739)) +- **agent-framework-core**: Support `detail` field in OpenAI Chat API `image_url` payload ([#4756](https://github.com/microsoft/agent-framework/pull/4756)) +- **agent-framework-anthropic**: [BREAKING] Refactor middleware layering and split Anthropic raw client ([#4746](https://github.com/microsoft/agent-framework/pull/4746)) +- **agent-framework-github-copilot**: Emit tool call events in GitHubCopilotAgent streaming ([4711](https://github.com/microsoft/agent-framework/pull/4711)) + +### Fixed + +- **agent-framework-core**: Validate approval responses against the server-side pending request registry ([#4548](https://github.com/microsoft/agent-framework/pull/4548)) +- **agent-framework-devui**: Validate function approval responses in the DevUI executor ([#4598](https://github.com/microsoft/agent-framework/pull/4598)) +- **agent-framework-azurefunctions**: Use `deepcopy` for state snapshots so nested mutations are detected in durable workflow activities ([#4518](https://github.com/microsoft/agent-framework/pull/4518)) +- **agent-framework-bedrock**: Fix `BedrockChatClient` sending invalid toolChoice `"none"` to the Bedrock API ([#4535](https://github.com/microsoft/agent-framework/pull/4535)) +- **agent-framework-core**: Fix type hint for `Case` and `Default` ([#3985](https://github.com/microsoft/agent-framework/pull/3985)) +- **agent-framework-core**: Fix duplicate tool names between supplied tools and MCP servers ([#4649](https://github.com/microsoft/agent-framework/pull/4649)) +- **agent-framework-core**: Fix `_deduplicate_messages` catch-all branch dropping valid repeated messages ([#4716](https://github.com/microsoft/agent-framework/pull/4716)) +- **samples**: Fix Azure Redis sample missing session for history persistence ([#4692](https://github.com/microsoft/agent-framework/pull/4692)) +- **agent-framework-core**: Fix thread serialization for multi-turn tool calls ([#4684](https://github.com/microsoft/agent-framework/pull/4684)) +- **agent-framework-core**: Fix `RUN_FINISHED.interrupt` to accumulate all interrupts when multiple tools need approval ([#4717](https://github.com/microsoft/agent-framework/pull/4717)) +- **agent-framework-azurefunctions**: Fix missing methods on the `Content` class in durable tasks ([#4738](https://github.com/microsoft/agent-framework/pull/4738)) +- **agent-framework-core**: Fix `ENABLE_SENSITIVE_DATA` being ignored when set after module import ([#4743](https://github.com/microsoft/agent-framework/pull/4743)) +- **agent-framework-a2a**: Fix `A2AAgent` to invoke context providers before and after run ([#4757](https://github.com/microsoft/agent-framework/pull/4757)) +- **agent-framework-core**: Fix MCP tool schema normalization for zero-argument tools missing the `properties` key ([#4771](https://github.com/microsoft/agent-framework/pull/4771)) + ## [1.0.0rc4] - 2026-03-11 ### Added @@ -768,7 +817,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai** For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/). -[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...HEAD +[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...HEAD +[1.0.0rc5]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc4...python-1.0.0rc5 [1.0.0rc4]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc3...python-1.0.0rc4 [1.0.0rc3]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc2...python-1.0.0rc3 [1.0.0rc2]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc1...python-1.0.0rc2 diff --git a/python/packages/a2a/pyproject.toml b/python/packages/a2a/pyproject.toml index b26aa58e25..52c0d762d1 100644 --- a/python/packages/a2a/pyproject.toml +++ b/python/packages/a2a/pyproject.toml @@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "a2a-sdk>=0.3.5,<0.3.24", ] diff --git a/python/packages/ag-ui/pyproject.toml b/python/packages/ag-ui/pyproject.toml index 38aa31a377..bb1e963023 100644 --- a/python/packages/ag-ui/pyproject.toml +++ b/python/packages/ag-ui/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agent-framework-ag-ui" -version = "1.0.0b260311" +version = "1.0.0b260319" description = "AG-UI protocol integration for Agent Framework" readme = "README.md" license-files = ["LICENSE"] @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "ag-ui-protocol==0.1.13", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" diff --git a/python/packages/anthropic/pyproject.toml b/python/packages/anthropic/pyproject.toml index 6212ed93a0..9b294c0f67 100644 --- a/python/packages/anthropic/pyproject.toml +++ b/python/packages/anthropic/pyproject.toml @@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "anthropic>=0.80.0,<0.80.1", ] diff --git a/python/packages/azure-ai-search/pyproject.toml b/python/packages/azure-ai-search/pyproject.toml index 71ef0bf88b..af62c00deb 100644 --- a/python/packages/azure-ai-search/pyproject.toml +++ b/python/packages/azure-ai-search/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "azure-search-documents>=11.7.0b2,<11.7.0b3", ] diff --git a/python/packages/azure-ai/pyproject.toml b/python/packages/azure-ai/pyproject.toml index 40f849f630..4b0024fe96 100644 --- a/python/packages/azure-ai/pyproject.toml +++ b/python/packages/azure-ai/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure AI Foundry integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc4" +version = "1.0.0rc5" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "azure-ai-agents>=1.2.0b5,<1.2.0b6", "azure-ai-inference>=1.0.0b9,<1.0.0b10", "aiohttp>=3.7.0,<4", diff --git a/python/packages/azure-cosmos/pyproject.toml b/python/packages/azure-cosmos/pyproject.toml index 4de7f1029c..cbb8188de0 100644 --- a/python/packages/azure-cosmos/pyproject.toml +++ b/python/packages/azure-cosmos/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "azure-cosmos>=4.3.0,<5", ] diff --git a/python/packages/azurefunctions/pyproject.toml b/python/packages/azurefunctions/pyproject.toml index c1de71aa3b..be8dee5e4a 100644 --- a/python/packages/azurefunctions/pyproject.toml +++ b/python/packages/azurefunctions/pyproject.toml @@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "agent-framework-durabletask", "azure-functions>=1.24.0,<2", "azure-functions-durable>=1.3.1,<2", diff --git a/python/packages/bedrock/pyproject.toml b/python/packages/bedrock/pyproject.toml index e85f3e2ac4..90b134e068 100644 --- a/python/packages/bedrock/pyproject.toml +++ b/python/packages/bedrock/pyproject.toml @@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "boto3>=1.35.0,<2.0.0", "botocore>=1.35.0,<2.0.0", ] diff --git a/python/packages/chatkit/pyproject.toml b/python/packages/chatkit/pyproject.toml index a5cc39a594..679891d115 100644 --- a/python/packages/chatkit/pyproject.toml +++ b/python/packages/chatkit/pyproject.toml @@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "openai-chatkit>=1.4.1,<2.0.0", ] diff --git a/python/packages/claude/pyproject.toml b/python/packages/claude/pyproject.toml index cf836caa5c..696f06cb9f 100644 --- a/python/packages/claude/pyproject.toml +++ b/python/packages/claude/pyproject.toml @@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "claude-agent-sdk>=0.1.36,<0.1.49", ] diff --git a/python/packages/copilotstudio/pyproject.toml b/python/packages/copilotstudio/pyproject.toml index 4c6ea7a292..846325b2ce 100644 --- a/python/packages/copilotstudio/pyproject.toml +++ b/python/packages/copilotstudio/pyproject.toml @@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework." authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2", ] diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index a6eb902ccd..a94d5c6f1a 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc4" +version = "1.0.0rc5" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" diff --git a/python/packages/declarative/pyproject.toml b/python/packages/declarative/pyproject.toml index aedf2617a2..e14843fb6f 100644 --- a/python/packages/declarative/pyproject.toml +++ b/python/packages/declarative/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "powerfx>=0.0.32,<0.0.35; python_version < '3.14'", "pyyaml>=6.0,<7.0", ] diff --git a/python/packages/devui/pyproject.toml b/python/packages/devui/pyproject.toml index 5ab31b16fa..54cd91b04f 100644 --- a/python/packages/devui/pyproject.toml +++ b/python/packages/devui/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" 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.0rc4", + "agent-framework-core>=1.0.0rc5", "fastapi>=0.115.0,<0.133.1", "uvicorn[standard]>=0.30.0,<0.42.0" ] diff --git a/python/packages/durabletask/pyproject.toml b/python/packages/durabletask/pyproject.toml index eff4d7825d..7d41b41000 100644 --- a/python/packages/durabletask/pyproject.toml +++ b/python/packages/durabletask/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "durabletask>=1.3.0,<2", "durabletask-azuremanaged>=1.3.0,<2", "python-dateutil>=2.8.0,<3", diff --git a/python/packages/foundry_local/pyproject.toml b/python/packages/foundry_local/pyproject.toml index a67a4302d8..3f1a5846f3 100644 --- a/python/packages/foundry_local/pyproject.toml +++ b/python/packages/foundry_local/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "foundry-local-sdk>=0.5.1,<0.5.2", ] diff --git a/python/packages/github_copilot/pyproject.toml b/python/packages/github_copilot/pyproject.toml index 18f81026c1..55335c7279 100644 --- a/python/packages/github_copilot/pyproject.toml +++ b/python/packages/github_copilot/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "github-copilot-sdk>=0.1.31,<0.1.33; python_version >= '3.11'", ] diff --git a/python/packages/lab/pyproject.toml b/python/packages/lab/pyproject.toml index 8345843d2b..a9dd359e7c 100644 --- a/python/packages/lab/pyproject.toml +++ b/python/packages/lab/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -22,7 +22,7 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", ] [project.optional-dependencies] diff --git a/python/packages/mem0/pyproject.toml b/python/packages/mem0/pyproject.toml index 081843b4aa..9a21e15116 100644 --- a/python/packages/mem0/pyproject.toml +++ b/python/packages/mem0/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "mem0ai>=1.0.0,<2", ] diff --git a/python/packages/ollama/pyproject.toml b/python/packages/ollama/pyproject.toml index 17e1540cbd..f8c5cd1b7e 100644 --- a/python/packages/ollama/pyproject.toml +++ b/python/packages/ollama/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" 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.0rc4", + "agent-framework-core>=1.0.0rc5", "ollama>=0.5.3,<0.5.4", ] diff --git a/python/packages/orchestrations/pyproject.toml b/python/packages/orchestrations/pyproject.toml index d4a49e10ae..fc35d01b05 100644 --- a/python/packages/orchestrations/pyproject.toml +++ b/python/packages/orchestrations/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", ] [tool.uv] diff --git a/python/packages/purview/pyproject.toml b/python/packages/purview/pyproject.toml index 5974ef0275..71aef81b9a 100644 --- a/python/packages/purview/pyproject.toml +++ b/python/packages/purview/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" 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.0rc4", + "agent-framework-core>=1.0.0rc5", "azure-core>=1.30.0,<2", "httpx>=0.27.0,<0.29", ] diff --git a/python/packages/redis/pyproject.toml b/python/packages/redis/pyproject.toml index 70abe25bf9..3b2d3442ed 100644 --- a/python/packages/redis/pyproject.toml +++ b/python/packages/redis/pyproject.toml @@ -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.0b260311" +version = "1.0.0b260319" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core>=1.0.0rc4", + "agent-framework-core>=1.0.0rc5", "redis>=6.4.0,<7.2.1", "redisvl>=0.11.0,<0.16", "numpy>=2.2.6,<3" diff --git a/python/pyproject.toml b/python/pyproject.toml index d5b91a641b..f955062de1 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] readme = "README.md" requires-python = ">=3.10" -version = "1.0.0rc4" +version = "1.0.0rc5" license-files = ["LICENSE"] urls.homepage = "https://aka.ms/agent-framework" urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" @@ -23,7 +23,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "agent-framework-core[all]==1.0.0rc4", + "agent-framework-core[all]==1.0.0rc5", ] [dependency-groups] diff --git a/python/uv.lock b/python/uv.lock index 992155f23f..f55686893e 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -91,7 +91,7 @@ wheels = [ [[package]] name = "agent-framework" -version = "1.0.0rc4" +version = "1.0.0rc5" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0rc4" +version = "1.0.0rc5" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0rc4" +version = "1.0.0rc5" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" 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.0b260311" +version = "1.0.0b260319" source = { editable = "packages/redis" } dependencies = [ { name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, From 8fc19a34378ebb469fd9481a4ab1f080e9bf2556 Mon Sep 17 00:00:00 2001 From: Giles Odigwe <79032838+giles17@users.noreply.github.com> Date: Fri, 20 Mar 2026 01:53:36 -0700 Subject: [PATCH 10/19] Python: Deprecate Azure AI v1 (Persistent Agents API) helper methods (#4804) * Deprecate Azure AI v1 (Persistent Agents API) helper methods Add DeprecationWarning to v1 classes and functions that have been superseded by the v2 (Projects/Responses) API: - AzureAIAgentsProvider -> use AzureAIProjectAgentProvider - AzureAIAgentClient -> use AzureAIClient - AzureAIAgentOptions -> use AzureAIProjectAgentOptions - to_azure_ai_agent_tools() -> use to_azure_ai_tools() - from_azure_ai_agent_tools() -> use from_azure_ai_tools() - AzureAIAgentClient static tool factory methods -> use AzureAIClient equivalents All v1 components still function but emit warnings to guide migration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add deprecation warnings to AzureAIAgentsProvider methods Mark create_agent(), get_agent(), and as_agent() as deprecated individually, pointing to AzureAIProjectAgentProvider equivalents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../_agent_provider.py | 41 +++++++++++++ .../agent_framework_azure_ai/_chat_client.py | 58 ++++++++++++++++++- .../agent_framework_azure_ai/_shared.py | 21 +++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py index c4b84c0310..9b2a72cd2f 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys +import warnings from collections.abc import Callable, Sequence from typing import Any, Generic, cast @@ -49,6 +50,10 @@ OptionsCoT = TypeVar( class AzureAIAgentsProvider(Generic[OptionsCoT]): """Provider for Azure AI Agent Service V1 (Persistent Agents API). + .. deprecated:: + AzureAIAgentsProvider is deprecated and will be removed in a future release. + Use :class:`AzureAIProjectAgentProvider` instead for the V2 (Projects/Responses) API. + This provider enables creating, retrieving, and wrapping Azure AI agents as Agent instances. It manages the underlying AgentsClient lifecycle and provides a high-level interface for agent operations. @@ -114,6 +119,12 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): Raises: ValueError: If required parameters are missing or invalid. """ + warnings.warn( + "AzureAIAgentsProvider is deprecated and will be removed in a future release; " + "use AzureAIProjectAgentProvider instead for the V2 (Projects/Responses) API.", + DeprecationWarning, + stacklevel=2, + ) self._settings = load_settings( AzureAISettings, env_prefix="AZURE_AI_", @@ -177,6 +188,10 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): ) -> Agent[OptionsCoT]: """Create a new agent on the Azure AI service and return a Agent. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIProjectAgentProvider.create_agent` instead. + This method creates a persistent agent on the Azure AI service with the specified configuration and returns a local Agent instance for interaction. @@ -209,6 +224,12 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): tools=get_weather, ) """ + warnings.warn( + "AzureAIAgentsProvider.create_agent() is deprecated and will be removed in a future release; " + "use AzureAIProjectAgentProvider.create_agent() instead.", + DeprecationWarning, + stacklevel=2, + ) resolved_model = model or self._settings.get("model_deployment_name") if not resolved_model: raise ValueError( @@ -271,6 +292,10 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): ) -> Agent[OptionsCoT]: """Retrieve an existing agent from the service and return a Agent. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIProjectAgentProvider.get_agent` instead. + This method fetches an agent by ID from the Azure AI service and returns a local Agent instance for interaction. @@ -299,6 +324,12 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): # With function tools agent = await provider.get_agent("agent-123", tools=my_function) """ + warnings.warn( + "AzureAIAgentsProvider.get_agent() is deprecated and will be removed in a future release; " + "use AzureAIProjectAgentProvider.get_agent() instead.", + DeprecationWarning, + stacklevel=2, + ) agent = await self._agents_client.get_agent(id) # Validate function tools @@ -323,6 +354,10 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): ) -> Agent[OptionsCoT]: """Wrap an existing Agent SDK object as a Agent without making HTTP calls. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIProjectAgentProvider.as_agent` instead. + Use this method when you already have an Agent object from a previous SDK operation and want to use it with the Agent Framework. @@ -354,6 +389,12 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): # Wrap as Agent chat_agent = provider.as_agent(sdk_agent) """ + warnings.warn( + "AzureAIAgentsProvider.as_agent() is deprecated and will be removed in a future release; " + "use AzureAIProjectAgentProvider.as_agent() instead.", + DeprecationWarning, + stacklevel=2, + ) # Validate function tools normalized_tools = normalize_tools(tools) self._validate_function_tools(agent.tools, normalized_tools) diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 63db1663d8..818338a861 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -8,6 +8,7 @@ import logging import os import re import sys +import warnings from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, MutableMapping, Sequence from typing import Any, ClassVar, Generic, TypedDict, cast @@ -118,6 +119,10 @@ __all__ = ["AzureAIAgentClient", "AzureAIAgentOptions"] class AzureAIAgentOptions(ChatOptions, total=False): """Azure AI Foundry Agent Service-specific options dict. + .. deprecated:: + AzureAIAgentOptions is deprecated and will be removed in a future release. + Use :class:`AzureAIProjectAgentOptions` instead for the V2 (Projects/Responses) API. + Extends base ChatOptions with Azure AI Agent Service parameters. Azure AI Agents provides a managed agent runtime with built-in tools for code interpreter, file search, and web search. @@ -212,7 +217,12 @@ class AzureAIAgentClient( BaseChatClient[AzureAIAgentOptionsT], Generic[AzureAIAgentOptionsT], ): - """Azure AI Agent Chat client with middleware, telemetry, and function invocation support.""" + """Azure AI Agent Chat client with middleware, telemetry, and function invocation support. + + .. deprecated:: + AzureAIAgentClient is deprecated and will be removed in a future release. + Use :class:`AzureAIClient` instead for the V2 (Projects/Responses) API. + """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai" # type: ignore[reportIncompatibleVariableOverride, misc] STORES_BY_DEFAULT: ClassVar[bool] = True # type: ignore[reportIncompatibleVariableOverride, misc] @@ -227,6 +237,10 @@ class AzureAIAgentClient( ) -> CodeInterpreterTool: """Create a code interpreter tool configuration for Azure AI Agents. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIClient.get_code_interpreter_tool` instead. + Keyword Args: file_ids: List of uploaded file IDs or Content objects to make available to the code interpreter. Accepts plain strings or Content.from_hosted_file() @@ -256,6 +270,12 @@ class AzureAIAgentClient( agent = ChatAgent(client, tools=[tool]) """ + warnings.warn( + "AzureAIAgentClient.get_code_interpreter_tool() is deprecated and will be removed in a future release; " + "use AzureAIClient.get_code_interpreter_tool() instead.", + DeprecationWarning, + stacklevel=2, + ) resolved = resolve_file_ids(file_ids) return CodeInterpreterTool(file_ids=resolved, data_sources=data_sources) @@ -266,6 +286,10 @@ class AzureAIAgentClient( ) -> FileSearchTool: """Create a file search tool configuration for Azure AI Agents. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIClient.get_file_search_tool` instead. + Keyword Args: vector_store_ids: List of vector store IDs to search within. @@ -282,6 +306,12 @@ class AzureAIAgentClient( ) agent = ChatAgent(client, tools=[tool]) """ + warnings.warn( + "AzureAIAgentClient.get_file_search_tool() is deprecated and will be removed in a future release; " + "use AzureAIClient.get_file_search_tool() instead.", + DeprecationWarning, + stacklevel=2, + ) return FileSearchTool(vector_store_ids=vector_store_ids) @staticmethod @@ -293,6 +323,10 @@ class AzureAIAgentClient( ) -> BingGroundingTool | BingCustomSearchTool: """Create a web search tool configuration for Azure AI Agents. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIClient.get_web_search_tool` instead. + For Azure AI Agents, web search uses Bing Grounding or Bing Custom Search. If no arguments are provided, attempts to read from environment variables. If no connection IDs are found, raises ValueError. @@ -333,6 +367,12 @@ class AzureAIAgentClient( agent = ChatAgent(client, tools=[tool]) """ + warnings.warn( + "AzureAIAgentClient.get_web_search_tool() is deprecated and will be removed in a future release; " + "use AzureAIClient.get_web_search_tool() instead.", + DeprecationWarning, + stacklevel=2, + ) # Try explicit Bing Custom Search parameters first, then environment variables resolved_custom_connection = bing_custom_connection_id or os.environ.get("BING_CUSTOM_CONNECTION_ID") resolved_custom_instance = bing_custom_instance_id or os.environ.get("BING_CUSTOM_INSTANCE_NAME") @@ -368,6 +408,10 @@ class AzureAIAgentClient( ) -> McpTool: """Create a hosted MCP tool configuration for Azure AI Agents. + .. deprecated:: + This method is deprecated and will be removed in a future release. + Use :meth:`AzureAIClient.get_mcp_tool` instead. + This configures an MCP (Model Context Protocol) server that will be called by Azure AI's service. The tools from this MCP server are executed remotely by Azure AI, not locally by your application. @@ -400,6 +444,12 @@ class AzureAIAgentClient( ) agent = ChatAgent(client, tools=[tool]) """ + warnings.warn( + "AzureAIAgentClient.get_mcp_tool() is deprecated and will be removed in a future release; " + "use AzureAIClient.get_mcp_tool() instead.", + DeprecationWarning, + stacklevel=2, + ) mcp_tool = McpTool( server_label=name.replace(" ", "_"), server_url=url or "", @@ -511,6 +561,12 @@ class AzureAIAgentClient( client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential) response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ + warnings.warn( + "AzureAIAgentClient is deprecated and will be removed in a future release; " + "use AzureAIClient instead for the V2 (Projects/Responses) API.", + DeprecationWarning, + stacklevel=2, + ) azure_ai_settings = load_settings( AzureAISettings, env_prefix="AZURE_AI_", diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 35b665e932..7f5f770e36 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import sys +import warnings from collections.abc import Mapping, MutableMapping, Sequence from typing import Any, cast @@ -158,6 +159,10 @@ def to_azure_ai_agent_tools( ) -> list[ToolDefinition | dict[str, Any]]: """Convert Agent Framework tools to Azure AI V1 SDK tool definitions. + .. deprecated:: + This function is deprecated and will be removed in a future release. + Use :func:`to_azure_ai_tools` instead for the V2 (Projects/Responses) API. + Handles FunctionTool instances and dict-based tools from static factory methods. Args: @@ -170,6 +175,12 @@ def to_azure_ai_agent_tools( Raises: ValueError: If tool configuration is invalid. """ + warnings.warn( + "to_azure_ai_agent_tools() is deprecated and will be removed in a future release; " + "use to_azure_ai_tools() instead for the V2 (Projects/Responses) API.", + DeprecationWarning, + stacklevel=2, + ) if not tools: return [] @@ -208,12 +219,22 @@ def from_azure_ai_agent_tools( ) -> list[dict[str, Any]]: """Convert Azure AI V1 SDK tool definitions to dict-based tools. + .. deprecated:: + This function is deprecated and will be removed in a future release. + Use :func:`from_azure_ai_tools` instead for the V2 (Projects/Responses) API. + Args: tools: Sequence of Azure AI V1 SDK tool definitions. Returns: List of dict-based tool definitions. """ + warnings.warn( + "from_azure_ai_agent_tools() is deprecated and will be removed in a future release; " + "use from_azure_ai_tools() instead for the V2 (Projects/Responses) API.", + DeprecationWarning, + stacklevel=2, + ) if not tools: return [] From 81e2336d476ed40a93c6a205658588cc990c18c7 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 20 Mar 2026 11:09:46 +0100 Subject: [PATCH 11/19] Python: avoid duplicate agent response telemetry (#4685) * Python: avoid duplicate agent response telemetry * Python: conditionally suppress duplicate agent telemetry * Simplify telemetry ownership tracking Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Aggregate token usage from inner chat spans on invoke_agent span The invoke_agent span now carries the aggregated input/output token counts from all inner chat completion spans that occur during an agent run. Previously, when inner ChatTelemetryLayer spans captured usage, the outer AgentTelemetryLayer skipped setting usage entirely to avoid duplication. Now a new INNER_ACCUMULATED_USAGE context variable tracks cumulative usage across all inner completions, and the agent span always reports the total. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../core/agent_framework/observability.py | 175 +++++++++++++----- .../core/tests/core/test_observability.py | 100 +++++++++- 2 files changed, 222 insertions(+), 53 deletions(-) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index a0cbd6a1a0..7462181036 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -14,6 +14,7 @@ Commonly used exports: from __future__ import annotations import contextlib +import contextvars import json import logging import os @@ -65,6 +66,7 @@ if TYPE_CHECKING: # pragma: no cover GeneratedEmbeddings, Message, ResponseStream, + UsageDetails, ) ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel) @@ -93,6 +95,18 @@ ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]") logger = logging.getLogger("agent_framework") +INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS: Final[contextvars.ContextVar[set[str] | None]] = contextvars.ContextVar( + "inner_response_telemetry_captured_fields", default=None +) +INNER_RESPONSE_ID_CAPTURED_FIELD: Final[str] = "response_id" +INNER_USAGE_CAPTURED_FIELD: Final[str] = "usage" + +# Tracks accumulated token usage from all inner chat completion spans within an agent invoke. +INNER_ACCUMULATED_USAGE: Final[contextvars.ContextVar[UsageDetails | None]] = contextvars.ContextVar( + "inner_accumulated_usage", default=None +) + + OTEL_METRICS: Final[str] = "__otel_metrics__" TOKEN_USAGE_BUCKET_BOUNDARIES: Final[tuple[float, ...]] = ( 1, @@ -1314,6 +1328,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): operation_duration_histogram=getattr(self, "duration_histogram", None), duration=duration, ) + _mark_inner_response_telemetry_captured(response) if ( OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and isinstance(response, ChatResponse) @@ -1373,6 +1388,7 @@ class ChatTelemetryLayer(Generic[OptionsCoT]): operation_duration_histogram=getattr(self, "duration_histogram", None), duration=duration, ) + _mark_inner_response_telemetry_captured(response) if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: finish_reason = cast( "FinishReason | None", @@ -1527,8 +1543,6 @@ class AgentTelemetryLayer: super().run, # type: ignore[misc] ) provider_name = str(self.otel_provider_name) - capture_usage = bool(getattr(self, "_otel_capture_usage", True)) - if not OBSERVABILITY_SETTINGS.ENABLED: return super_run( # type: ignore[no-any-return] messages=messages, @@ -1557,23 +1571,34 @@ class AgentTelemetryLayer: **merged_client_kwargs, ) + inner_response_telemetry_captured_fields: set[str] = set() + inner_response_telemetry_captured_fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set( + inner_response_telemetry_captured_fields + ) + inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({}) + if stream: - run_result: object = super_run( - messages=messages, - stream=True, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) - if isinstance(run_result, ResponseStream): - result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] - elif isinstance(run_result, Awaitable): - result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] - else: - raise RuntimeError("Streaming telemetry requires a ResponseStream result.") + try: + run_result: object = super_run( + messages=messages, + stream=True, + session=session, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) + if isinstance(run_result, ResponseStream): + result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType] + elif isinstance(run_result, Awaitable): + result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + else: + raise RuntimeError("Streaming telemetry requires a ResponseStream result.") + except Exception: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) + raise # Create span directly without trace.use_span() context attachment. # Streaming spans are closed asynchronously in cleanup hooks, which run @@ -1613,8 +1638,11 @@ class AgentTelemetryLayer: response_attributes = _get_response_attributes( attributes, response, - capture_usage=capture_usage, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) _capture_response(span=span, attributes=response_attributes, duration=duration) if ( OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED @@ -1630,6 +1658,8 @@ class AgentTelemetryLayer: except Exception as exception: capture_exception(span=span, exception=exception, timestamp=time_ns()) finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) _close_span() # Register a weak reference callback to close the span if stream is garbage collected @@ -1641,41 +1671,52 @@ class AgentTelemetryLayer: return wrapped_stream async def _run() -> AgentResponse: - with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: - _capture_messages( - span=span, - provider_name=provider_name, - messages=messages, - system_instructions=_get_instructions_from_options(merged_options), - ) - start_time_stamp = perf_counter() - try: - response: AgentResponse[Any] = await super_run( - messages=messages, - stream=False, - session=session, - compaction_strategy=compaction_strategy, - tokenizer=tokenizer, - function_invocation_kwargs=function_invocation_kwargs, - client_kwargs=client_kwargs, - **kwargs, - ) - except Exception as exception: - capture_exception(span=span, exception=exception, timestamp=time_ns()) - raise - duration = perf_counter() - start_time_stamp - if response: - response_attributes = _get_response_attributes(attributes, response, capture_usage=capture_usage) - _capture_response(span=span, attributes=response_attributes, duration=duration) - if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + try: + with _get_span(attributes=attributes, span_name_attribute=OtelAttr.AGENT_NAME) as span: + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages: _capture_messages( span=span, provider_name=provider_name, - messages=response.messages, - output=True, + messages=messages, + system_instructions=_get_instructions_from_options(merged_options), ) - return response # type: ignore[return-value,no-any-return] + start_time_stamp = perf_counter() + try: + response: AgentResponse[Any] = await super_run( + messages=messages, + stream=False, + session=session, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=client_kwargs, + **kwargs, + ) + except Exception as exception: + capture_exception(span=span, exception=exception, timestamp=time_ns()) + raise + duration = perf_counter() - start_time_stamp + if response: + response_attributes = _get_response_attributes( + attributes, + response, + capture_response_id=INNER_RESPONSE_ID_CAPTURED_FIELD + not in inner_response_telemetry_captured_fields, + capture_usage=INNER_USAGE_CAPTURED_FIELD not in inner_response_telemetry_captured_fields, + ) + _apply_accumulated_usage(response_attributes, inner_response_telemetry_captured_fields) + _capture_response(span=span, attributes=response_attributes, duration=duration) + if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and response.messages: + _capture_messages( + span=span, + provider_name=provider_name, + messages=response.messages, + output=True, + ) + return response # type: ignore[return-value,no-any-return] + finally: + INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token) + INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token) return _run() @@ -1931,14 +1972,46 @@ def _to_otel_part(content: Content) -> dict[str, Any] | None: return None +def _mark_inner_response_telemetry_captured(response: ChatResponse | AgentResponse) -> None: + """Record when an inner chat telemetry span already captured response metadata.""" + captured_fields = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.get() + if captured_fields is None: + return + if response.response_id: + captured_fields.add(INNER_RESPONSE_ID_CAPTURED_FIELD) + if response.usage_details: + captured_fields.add(INNER_USAGE_CAPTURED_FIELD) + accumulated = INNER_ACCUMULATED_USAGE.get() + if accumulated is not None: + from ._types import add_usage_details + + INNER_ACCUMULATED_USAGE.set(add_usage_details(accumulated, response.usage_details)) + + +def _apply_accumulated_usage(attributes: dict[str, Any], captured_fields: set[str]) -> None: + """Apply accumulated usage from inner chat spans to the invoke_agent span attributes.""" + if INNER_USAGE_CAPTURED_FIELD not in captured_fields: + return + accumulated = INNER_ACCUMULATED_USAGE.get() + if not accumulated: + return + input_tokens = accumulated.get("input_token_count") + if input_tokens: + attributes[OtelAttr.INPUT_TOKENS] = input_tokens + output_tokens = accumulated.get("output_token_count") + if output_tokens: + attributes[OtelAttr.OUTPUT_TOKENS] = output_tokens + + def _get_response_attributes( attributes: dict[str, Any], response: ChatResponse | AgentResponse, *, + capture_response_id: bool = True, capture_usage: bool = True, ) -> dict[str, Any]: """Get the response attributes from a response.""" - if response.response_id: + if capture_response_id and response.response_id: attributes[OtelAttr.RESPONSE_ID] = response.response_id finish_reason = getattr(response, "finish_reason", None) if not finish_reason: diff --git a/python/packages/core/tests/core/test_observability.py b/python/packages/core/tests/core/test_observability.py index 7982985b94..5152712b8c 100644 --- a/python/packages/core/tests/core/test_observability.py +++ b/python/packages/core/tests/core/test_observability.py @@ -11,6 +11,7 @@ from opentelemetry.trace import StatusCode from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, + Agent, AgentResponse, BaseChatClient, ChatResponse, @@ -473,10 +474,10 @@ def mock_chat_agent(): @pytest.mark.parametrize("enable_sensitive_data", [True, False], indirect=True) -async def test_agent_instrumentation_enabled( +async def test_agent_span_captures_response_telemetry_without_inner_chat_span( mock_chat_agent: SupportsAgentRun, span_exporter: InMemorySpanExporter, enable_sensitive_data ): - """Test that when agent diagnostics are enabled, telemetry is applied.""" + """Agent spans should retain response telemetry when no inner chat span owns it.""" agent = mock_chat_agent() @@ -492,6 +493,7 @@ async def test_agent_instrumentation_enabled( assert span.attributes[OtelAttr.AGENT_NAME] == "test_agent" assert span.attributes[OtelAttr.AGENT_DESCRIPTION] == "Test agent description" assert span.attributes[OtelAttr.REQUEST_MODEL] == "TestModel" + assert span.attributes[OtelAttr.RESPONSE_ID] == "test_response_id" assert span.attributes[OtelAttr.INPUT_TOKENS] == 15 assert span.attributes[OtelAttr.OUTPUT_TOKENS] == 25 if enable_sensitive_data: @@ -1700,6 +1702,24 @@ def test_get_response_attributes_capture_usage_false(): assert OtelAttr.OUTPUT_TOKENS not in result +def test_get_response_attributes_capture_response_id_false(): + """Test _get_response_attributes skips response_id when capture_response_id is False.""" + from unittest.mock import Mock + + from agent_framework.observability import OtelAttr, _get_response_attributes + + response = Mock() + response.response_id = "resp_123" + response.finish_reason = None + response.raw_representation = None + response.usage_details = None + + attrs = {} + result = _get_response_attributes(attrs, response, capture_response_id=False) + + assert OtelAttr.RESPONSE_ID not in result + + # region Test _get_exporters_from_env @@ -2530,6 +2550,82 @@ async def test_layer_ordering_span_sequence_with_function_calling(span_exporter: assert sorted_spans[2].name.startswith("chat"), f"Third span should be 'chat', got '{sorted_spans[2].name}'" +@pytest.mark.parametrize("stream", [False, True]) +async def test_agent_and_chat_spans_do_not_duplicate_response_telemetry( + span_exporter: InMemorySpanExporter, stream: bool +): + """The inner chat span owns response-id; usage is aggregated on the agent span.""" + + class NestedTelemetryChatClient(ChatTelemetryLayer, BaseChatClient[Any]): + def service_url(self): + return "https://test.example.com" + + def _inner_get_response( + self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any + ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]: + if stream: + + async def _stream() -> AsyncIterable[ChatResponseUpdate]: + yield ChatResponseUpdate(contents=[Content.from_text("Nested")], role="assistant") + yield ChatResponseUpdate(contents=[Content.from_text(" response")], role="assistant") + + def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse: + return ChatResponse( + messages=[Message(role="assistant", text="Nested response")], + response_id="nested_resp_123", + usage_details=UsageDetails(input_token_count=11, output_token_count=22), + finish_reason="stop", + ) + + return ResponseStream(_stream(), finalizer=_finalize) + + async def _get() -> ChatResponse: + return ChatResponse( + messages=[Message(role="assistant", text="Nested response")], + response_id="nested_resp_123", + usage_details=UsageDetails(input_token_count=11, output_token_count=22), + finish_reason="stop", + ) + + return _get() + + agent = Agent( + client=NestedTelemetryChatClient(), + id="nested_agent_id", + name="nested_agent", + description="Nested telemetry agent", + default_options={"model_id": "NestedModel"}, + ) + + span_exporter.clear() + + if stream: + result_stream = agent.run("Test message", stream=True) + async for _ in result_stream: + pass + response = await result_stream.get_final_response() + else: + response = await agent.run("Test message") + + assert response is not None + + spans = span_exporter.get_finished_spans() + assert len(spans) == 2 + + span_by_operation = {span.attributes[OtelAttr.OPERATION.value]: span for span in spans} + agent_span = span_by_operation[OtelAttr.AGENT_INVOKE_OPERATION] + chat_span = span_by_operation[OtelAttr.CHAT_COMPLETION_OPERATION] + + assert chat_span.attributes[OtelAttr.RESPONSE_ID] == "nested_resp_123" + assert chat_span.attributes[OtelAttr.INPUT_TOKENS] == 11 + assert chat_span.attributes[OtelAttr.OUTPUT_TOKENS] == 22 + + assert OtelAttr.RESPONSE_ID not in agent_span.attributes + # The agent span carries the aggregated usage from all inner chat completions + assert agent_span.attributes[OtelAttr.INPUT_TOKENS] == 11 + assert agent_span.attributes[OtelAttr.OUTPUT_TOKENS] == 22 + + # region Test non-ASCII character handling in JSON serialization From 8edcb282f4cac845be1e6dc78f8b2235e37ea37a Mon Sep 17 00:00:00 2001 From: Evan Mattson <35585003+moonbox3@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:39:22 +0900 Subject: [PATCH 12/19] Update script to ping only on `waiting-for-author` label (#4812) * update script to ping only on certain waiting for author label * Update .github/scripts/stale_issue_pr_ping.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update .github/scripts/stale_issue_pr_ping.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix docstring --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/scripts/stale_issue_pr_ping.py | 31 +++++++++++++++-------- .github/tests/test_stale_issue_pr_ping.py | 16 +++++++----- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/.github/scripts/stale_issue_pr_ping.py b/.github/scripts/stale_issue_pr_ping.py index 0effcd5d75..9c865213ad 100644 --- a/.github/scripts/stale_issue_pr_ping.py +++ b/.github/scripts/stale_issue_pr_ping.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. -"""Scan open issues and PRs for stale follow-ups from external authors. +"""Scan open issues and PRs labeled 'waiting-for-author' for stale follow-ups. -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. +Team members manually add the 'waiting-for-author' label when they need a +response from the external author. If the author hasn't replied within +DAYS_THRESHOLD days of the last team comment, post a reminder and add the +'requested-info' label to prevent duplicate pings. """ from __future__ import annotations @@ -22,7 +24,8 @@ 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" +TRIGGER_LABEL = "waiting-for-author" +PINGED_LABEL = "requested-info" def get_team_members(g: Github, org: str, team_slug: str) -> set[str]: @@ -76,15 +79,21 @@ def should_ping( days_threshold: int, now: datetime, ) -> bool: - """Determine whether this issue/PR should be pinged.""" + """Determine whether this issue/PR should be pinged. + + Only issues/PRs carrying the 'waiting-for-author' label are candidates. + """ author = issue.user.login + # Skip if the trigger label is not present + if not any(label.name == TRIGGER_LABEL for label in issue.labels): + return False # 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): + # Skip if already pinged + if any(label.name == PINGED_LABEL for label in issue.labels): return False # Skip if no comments at all @@ -112,7 +121,7 @@ def should_ping( def ping(issue: Issue, dry_run: bool) -> bool: - """Post a reminder comment and add the needs-info label. Returns True on success.""" + """Post a reminder comment and add the 'requested-info' label. Returns True on success.""" author = issue.user.login kind = "PR" if issue.pull_request else "Issue" @@ -129,7 +138,7 @@ def ping(issue: Issue, dry_run: bool) -> bool: issue.create_comment(PING_COMMENT.format(author=author)) commented = True if not labeled: - issue.add_to_labels(LABEL) + issue.add_to_labels(PINGED_LABEL) labeled = True print(f" Pinged {kind} #{issue.number} (@{author})") return True @@ -184,9 +193,9 @@ def main() -> None: failed = [] scanned = 0 - print(f"Scanning open issues and PRs (threshold: {days_threshold} days)...\n") + print(f"Scanning open issues and PRs labeled '{TRIGGER_LABEL}' (threshold: {days_threshold} days)...\n") - for issue in repo.get_issues(state="open"): + for issue in repo.get_issues(state="open", labels=[TRIGGER_LABEL]): scanned += 1 if should_ping(issue, team_members, days_threshold, now): diff --git a/.github/tests/test_stale_issue_pr_ping.py b/.github/tests/test_stale_issue_pr_ping.py index f114a84ed8..b9a7ad5d43 100644 --- a/.github/tests/test_stale_issue_pr_ping.py +++ b/.github/tests/test_stale_issue_pr_ping.py @@ -15,8 +15,9 @@ import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) from stale_issue_pr_ping import ( - LABEL, + PINGED_LABEL, PING_COMMENT, + TRIGGER_LABEL, author_replied_after, find_last_team_comment, get_team_members, @@ -63,7 +64,10 @@ def _make_issue( issue.user = MagicMock() issue.user.login = author issue.number = number - issue.labels = [_make_label(n) for n in (labels or [])] + # Default to having the trigger label, since the API query pre-filters. + if labels is None: + labels = [TRIGGER_LABEL] + issue.labels = [_make_label(n) for n in labels] issue.comments = comment_count issue.pull_request = MagicMock() if pull_request else None if comments is not None: @@ -136,11 +140,11 @@ class TestShouldPing: assert should_ping(issue, TEAM, 4, NOW) is True def test_skip_team_member_author(self): - issue = _make_issue(author="alice", comment_count=1) + issue = _make_issue(author="alice", labels=[TRIGGER_LABEL], 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) + def test_skip_already_pinged(self): + issue = _make_issue(labels=[TRIGGER_LABEL, PINGED_LABEL], comment_count=1) assert should_ping(issue, TEAM, 4, NOW) is False def test_skip_no_comments(self): @@ -194,7 +198,7 @@ class TestPing: 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) + issue.add_to_labels.assert_called_once_with(PINGED_LABEL) @patch("stale_issue_pr_ping.time.sleep") def test_retry_on_failure(self, mock_sleep): From 88ea9d08c7575ce9165c4a2e4ee0086bc77567a8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:29:29 +0000 Subject: [PATCH 13/19] .NET: Update to OpenAI 2.9.1, Azure.AI.OpenAI 2.9.0-beta.1, Microsoft.Extensions.AI 10.4.0, and Azure.AI.Projects 2.0.0-beta.2 (#4613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * Update code for Microsoft.Extensions.AI.Abstractions 10.4.0 breaking changes - Rename FunctionApprovalRequestContent → ToolApprovalRequestContent - Rename FunctionApprovalResponseContent → ToolApprovalResponseContent - Rename UserInputRequestContent → ToolApprovalRequestContent - Rename UserInputResponseContent → ToolApprovalResponseContent - Update .FunctionCall property → .ToolCall with FunctionCallContent casts where needed - Update .Id property → .RequestId on the renamed types - Rename FunctionApprovalRequestEventGenerator → ToolApprovalRequestEventGenerator - Rename FunctionApprovalResponseEventGenerator → ToolApprovalResponseEventGenerator Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update OpenAI 2.9.1, ME.AI 10.4.0, fix breaking API changes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix remaining ME.AI 10.4.0 breaking changes: MCP approval types, .Output→.Outputs Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Use pattern matching with `when` for ToolApprovalRequestContent/FunctionCallContent Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Update Azure.AI.OpenAI to 2.9.0-beta.1 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix remaining GetResponsesClient(model) build failures for Azure.AI.OpenAI 2.9.0-beta.1 Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Address review feedback: remove redundant type checks in TestRequestAgent.cs and fix error message in AIAgentHostExecutor.cs Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Update Azure.AI.Projects to 2.0.0-beta.2 with namespace migration - Azure.AI.Projects 2.0.0-beta.1 → 2.0.0-beta.2 - Azure.AI.Projects.OpenAI → Azure.AI.Extensions.OpenAI (transitive) - Agent types moved to Azure.AI.Projects.Agents namespace - AgentRecord.Versions.Latest → AgentRecord.GetLatestVersion() - OpenAPIFunctionDefinition → OpenApiFunctionDefinition - BingCustomSearchToolParameters → BingCustomSearchToolOptions - MemorySearchPreviewTool.UpdateDelay → UpdateDelayInSecs - Azure.Identity 1.17.1 → 1.19.0 - Microsoft.Identity.Client.Extensions.Msal 4.78.0 → 4.83.1 Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Fix remaining type renames for Azure.AI.Projects 2.0.0-beta.2 - BrowserAutomationToolParameters → BrowserAutomationToolOptions - MemoryUpdateOptions.UpdateDelay stays as UpdateDelay (not renamed) - WaitForMemoriesUpdateAsync parameter order: pollingInterval before options - AIProjectAgentsOperations → AgentsClient Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> * Fix format errors and OpenTelemetry test for ME.AI 10.4.0 - Remove unused 'using Azure.AI.Extensions.OpenAI' and fix import ordering in Agent_With_AzureAIProject/Program.cs - Update OpenTelemetryAgentTests: gen_ai.tool.definitions is now always emitted regardless of EnableSensitiveData per ME.AI 10.4.0 change (dotnet/extensions#7346). Tool definitions are not considered sensitive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix GetRepoFolder() to work in git worktrees Use 'workflow-samples' directory as repo root marker instead of '.git', which fails in worktrees (.git is a file) and also matches too early when a '.github' folder exists in subdirectories. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix formatting: remove unused usings and fix import ordering dotnet format applied across 59 impacted projects. Primarily removes unnecessary 'using Azure.AI.Projects' where Azure.AI.Projects.Agents provides all needed types, and fixes import ordering per editorconfig. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Disable AzureAIAgentsPersistent integration tests (#4769) Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent which was removed in ME.AI 10.4.0 (renamed to ToolApprovalResponseContent), causing TypeLoadException at runtime. Mark all 6 test classes with IntegrationDisabled trait until Persistent ships a version targeting ME.AI 10.4.0+. Upstream fix: https://github.com/Azure/azure-sdk-for-net/pull/56929 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add README with compatibility note for AzureAI.Persistent (#4769) Documents that Azure.AI.Agents.Persistent 1.2.0-beta.9 is only compatible with ME.AI ≤10.3.0 and OpenAI ≤2.8.0 due to type renames in ME.AI 10.4.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix file encoding: restore UTF-8 BOM on Persistent test files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mark AzureAI.Persistent as IsPackable=false (#4769) Prevent shipping until Azure.AI.Agents.Persistent targets ME.AI 10.4.0+. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Moving IsPackable after import --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com> --- dotnet/Directory.Packages.props | 35 +++++---- .../AGUI/Step04_HumanInLoop/Client/Program.cs | 16 ++-- .../ServerFunctionApprovalClientAgent.cs | 24 +++--- .../ServerFunctionApprovalServerAgent.cs | 17 ++--- .../Agent_With_AzureAIProject/Program.cs | 2 +- .../Program.cs | 8 +- .../Agent_With_OpenAIResponses/Program.cs | 4 +- .../Agent_Step01_BasicSkills/Program.cs | 5 +- .../Agent_OpenAI_Step02_Reasoning/Program.cs | 4 +- .../OpenAIResponseClientAgent.cs | 9 ++- .../Program.cs | 4 +- .../Program.cs | 2 +- .../Program.cs | 10 +-- .../Program.cs | 3 +- .../Agents/Agent_Step11_Middleware/Program.cs | 6 +- .../Program.cs | 4 +- .../FoundryAgents_Step01.1_Basics/Program.cs | 2 +- .../FoundryAgents_Step01.2_Running/Program.cs | 2 +- .../Program.cs | 3 +- .../Program.cs | 6 +- .../Program.cs | 6 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Program.cs | 4 +- .../Program.cs | 4 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../FoundryAgents_Step21_WebSearch/Program.cs | 2 +- .../FoundryAgents_Step22_MemorySearch.csproj | 1 - .../Program.cs | 9 ++- .../FoundryAgent_Hosted_MCP/Program.cs | 11 +-- .../ResponseAgent_Hosted_MCP/Program.cs | 17 +++-- .../Agents/GroupChatToolApproval/Program.cs | 10 +-- .../Declarative/CustomerSupport/Program.cs | 2 +- .../Declarative/DeepResearch/Program.cs | 4 +- .../Declarative/FunctionTools/Program.cs | 2 +- .../Declarative/HostedWorkflow/Program.cs | 3 +- .../Declarative/InputArguments/Program.cs | 2 +- .../Declarative/InvokeFunctionTool/Program.cs | 2 +- .../Declarative/InvokeMcpTool/Program.cs | 2 +- .../Declarative/Marketing/Program.cs | 2 +- .../Declarative/StudentTeacher/Program.cs | 2 +- .../Declarative/ToolApproval/Program.cs | 2 +- .../OpenAIResponsesAgentClient.cs | 2 +- .../05-end-to-end/AgentWithPurview/Program.cs | 4 +- .../AgentThreadAndHITL.csproj | 2 +- .../AgentWithHostedMCP.csproj | 3 +- .../AgentWithHostedMCP/Program.cs | 3 +- .../AgentWithLocalTools.csproj | 2 +- .../AgentWithTextSearchRag.csproj | 2 +- .../AgentWithTools/AgentWithTools.csproj | 2 +- .../AgentsInWorkflows.csproj | 2 +- .../M365Agent/AFAgentApplication.cs | 6 +- ...rosoft.Agents.AI.AzureAI.Persistent.csproj | 2 + .../README.md | 19 +++++ .../AzureAIProjectChatClient.cs | 5 +- .../AzureAIProjectChatClientExtensions.cs | 13 ++-- .../Microsoft.Agents.AI.AzureAI.csproj | 1 - .../AgentResponseUpdateExtensions.cs | 4 +- .../FunctionApprovalRequestEventGenerator.cs | 20 +++-- .../FunctionApprovalResponseEventGenerator.cs | 10 +-- ...ingChatCompletionUpdateCollectionResult.cs | 2 + .../OpenAIResponseClientExtensions.cs | 12 ++- .../AzureAgentProvider.cs | 5 +- .../DefaultMcpToolHandler.cs | 8 +- .../ObjectModel/InvokeAzureAgentExecutor.cs | 2 +- .../ObjectModel/InvokeFunctionToolExecutor.cs | 2 +- .../ObjectModel/InvokeMcpToolExecutor.cs | 17 ++--- .../AIAgentHostOptions.cs | 2 +- .../Specialized/AIAgentHostExecutor.cs | 20 ++--- .../src/Shared/Foundry/Agents/AgentFactory.cs | 3 +- .../Workflows/Execution/WorkflowRunner.cs | 21 +++--- .../AIProjectClientCreateTests.cs | 6 +- .../AIProjectClientFixture.cs | 2 +- ...IAgentsChatClientAgentRunStreamingTests.cs | 4 + .../AzureAIAgentsChatClientAgentRunTests.cs | 4 + .../AzureAIAgentsPersistentCreateTests.cs | 4 + ...zureAIAgentsPersistentRunStreamingTests.cs | 4 + .../AzureAIAgentsPersistentRunTests.cs | 4 + ...gentsPersistentStructuredOutputRunTests.cs | 4 + ...AzureAIProjectChatClientExtensionsTests.cs | 19 ++--- .../TestDataUtil.cs | 2 +- .../FunctionApprovalTests.cs | 24 +++--- .../OpenAIResponsesIntegrationTests.cs | 75 +++++++++---------- .../OpenTelemetryAgentTests.cs | 42 ++++++++++- .../Agents/AgentProvider.cs | 2 +- .../Agents/FunctionToolAgentProvider.cs | 2 +- .../Agents/MarketingAgentProvider.cs | 2 +- .../Agents/MathChatAgentProvider.cs | 2 +- .../Agents/PoemAgentProvider.cs | 2 +- .../Agents/TestAgentProvider.cs | 2 +- .../Agents/VisionAgentProvider.cs | 2 +- .../Framework/WorkflowTest.cs | 2 +- .../InvokeToolWorkflowTest.cs | 10 +-- .../Events/ExternalInputRequestTest.cs | 17 +++-- .../Events/ExternalInputResponseTest.cs | 17 +++-- .../ObjectModel/InvokeMcpToolExecutorTest.cs | 40 +++++----- .../AIAgentHostExecutorTests.cs | 2 +- .../TestRequestAgent.cs | 61 ++++++--------- .../OpenAIResponseFixture.cs | 6 +- 101 files changed, 464 insertions(+), 358 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index dc840265d6..c5bd2191c4 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -19,11 +19,10 @@ - - - - - + + + + @@ -40,12 +39,12 @@ - + - - - + + + @@ -64,12 +63,12 @@ - - - - + + + + - + @@ -77,11 +76,11 @@ - + - + @@ -111,9 +110,9 @@ - + - + diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs index fafe9ccf83..5d770ff3fd 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/Program.cs @@ -59,14 +59,14 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa { switch (content) { - case FunctionApprovalRequestContent approvalRequest: - DisplayApprovalRequest(approvalRequest); + case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent fcc: + DisplayApprovalRequest(approvalRequest, fcc); - Console.Write($"\nApprove '{approvalRequest.FunctionCall.Name}'? (yes/no): "); + Console.Write($"\nApprove '{fcc.Name}'? (yes/no): "); string? userInput = Console.ReadLine(); bool approved = userInput?.ToUpperInvariant() is "YES" or "Y"; - FunctionApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved); if (approvalRequest.AdditionalProperties != null) { @@ -128,19 +128,19 @@ while ((input = Console.ReadLine()) != null && !input.Equals("exit", StringCompa } #pragma warning disable MEAI001 -static void DisplayApprovalRequest(FunctionApprovalRequestContent approvalRequest) +static void DisplayApprovalRequest(ToolApprovalRequestContent approvalRequest, FunctionCallContent fcc) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine(); Console.WriteLine("============================================================"); Console.WriteLine("APPROVAL REQUIRED"); Console.WriteLine("============================================================"); - Console.WriteLine($"Function: {approvalRequest.FunctionCall.Name}"); + Console.WriteLine($"Function: {fcc.Name}"); - if (approvalRequest.FunctionCall.Arguments != null) + if (fcc.Arguments != null) { Console.WriteLine("Arguments:"); - foreach (var arg in approvalRequest.FunctionCall.Arguments) + foreach (var arg in fcc.Arguments) { Console.WriteLine($" {arg.Key} = {arg.Value}"); } diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs index ee0191fd98..866bbfad31 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Client/ServerFunctionApprovalClientAgent.cs @@ -9,7 +9,7 @@ using ServerFunctionApproval; /// /// A delegating agent that handles server function approval requests and responses. -/// Transforms between FunctionApprovalRequestContent/FunctionApprovalResponseContent +/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent /// and the server's request_approval tool call pattern. /// internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent @@ -50,14 +50,14 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent } #pragma warning disable MEAI001 // Type is for evaluation purposes only - private static FunctionResultContent ConvertApprovalResponseToToolResult(FunctionApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions) + private static FunctionResultContent ConvertApprovalResponseToToolResult(ToolApprovalResponseContent approvalResponse, JsonSerializerOptions jsonOptions) { return new FunctionResultContent( - callId: approvalResponse.Id, + callId: approvalResponse.RequestId, result: JsonSerializer.SerializeToElement( new ApprovalResponse { - ApprovalId = approvalResponse.Id, + ApprovalId = approvalResponse.RequestId, Approved = approvalResponse.Approved }, jsonOptions)); @@ -89,7 +89,7 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent { List? result = null; - Dictionary approvalRequests = []; + Dictionary approvalRequests = []; for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) { var message = messages[messageIndex]; @@ -102,21 +102,21 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent var content = message.Contents[contentIndex]; // Handle pending approval requests (transform to tool call) - if (content is FunctionApprovalRequestContent approvalRequest && + if (content is ToolApprovalRequestContent approvalRequest && approvalRequest.AdditionalProperties?.TryGetValue("original_function", out var originalFunction) == true && originalFunction is FunctionCallContent original) { - approvalRequests[approvalRequest.Id] = approvalRequest; + approvalRequests[approvalRequest.RequestId] = approvalRequest; transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); transformedContents.Add(original); } // Handle pending approval responses (transform to tool result) - else if (content is FunctionApprovalResponseContent approvalResponse && - approvalRequests.TryGetValue(approvalResponse.Id, out var correspondingRequest)) + else if (content is ToolApprovalResponseContent approvalResponse && + approvalRequests.TryGetValue(approvalResponse.RequestId, out var correspondingRequest)) { transformedContents ??= CopyContentsUpToIndex(message.Contents, contentIndex); transformedContents.Add(ConvertApprovalResponseToToolResult(approvalResponse, jsonSerializerOptions)); - approvalRequests.Remove(approvalResponse.Id); + approvalRequests.Remove(approvalResponse.RequestId); correspondingRequest.AdditionalProperties?.Remove("original_function"); } // Skip historical approval content @@ -198,8 +198,8 @@ internal sealed class ServerFunctionApprovalClientAgent : DelegatingAIAgent var functionCallArgs = (Dictionary?)approvalRequest.FunctionArguments? .Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(Dictionary))); - var approvalRequestContent = new FunctionApprovalRequestContent( - id: approvalRequest.ApprovalId, + var approvalRequestContent = new ToolApprovalRequestContent( + requestId: approvalRequest.ApprovalId, new FunctionCallContent( callId: approvalRequest.ApprovalId, name: approvalRequest.FunctionName, diff --git a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs index 62209792f6..ff3e6ffbb1 100644 --- a/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs +++ b/dotnet/samples/02-agents/AGUI/Step04_HumanInLoop/Server/ServerFunctionApprovalServerAgent.cs @@ -9,7 +9,7 @@ using ServerFunctionApproval; /// /// A delegating agent that handles function approval requests on the server side. -/// Transforms between FunctionApprovalRequestContent/FunctionApprovalResponseContent +/// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent /// and the request_approval tool call pattern for client communication. /// internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent @@ -50,7 +50,7 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent } #pragma warning disable MEAI001 // Type is for evaluation purposes only - private static FunctionApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions) + private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions) { if (toolCall.Name != "request_approval" || toolCall.Arguments == null) { @@ -67,15 +67,15 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent throw new InvalidOperationException("Failed to deserialize approval request from tool call"); } - return new FunctionApprovalRequestContent( - id: request.ApprovalId, + return new ToolApprovalRequestContent( + requestId: request.ApprovalId, new FunctionCallContent( callId: request.ApprovalId, name: request.FunctionName, arguments: request.FunctionArguments)); } - private static FunctionApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, FunctionApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions) + private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions) { var approvalResponse = result.Result is JsonElement je ? (ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) : @@ -121,7 +121,7 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent // Track approval ID to original call ID mapping _ = new Dictionary(); #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. - Dictionary trackedRequestApprovalToolCalls = new(); // Remote approvals + Dictionary trackedRequestApprovalToolCalls = new(); // Remote approvals for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++) { var message = messages[messageIndex]; @@ -181,11 +181,10 @@ internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent { var content = update.Contents[i]; #pragma warning disable MEAI001 // Type is for evaluation purposes only - if (content is FunctionApprovalRequestContent request) + if (content is ToolApprovalRequestContent request && request.ToolCall is FunctionCallContent functionCall) { updatedContents ??= [.. update.Contents]; - var functionCall = request.FunctionCall; - var approvalId = request.Id; + var approvalId = request.RequestId; var approvalData = new ApprovalRequest { diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs index acdd0829ab..aab95d5b38 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIProject/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to create and use a AI agents with Azure Foundry Agents as the backend. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs index 6aca7f24b8..f29b850700 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureOpenAIResponses/Program.cs @@ -17,8 +17,8 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) - .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); + .GetResponsesClient() + .AsAIAgent(model: deploymentName, instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); @@ -29,8 +29,8 @@ Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); AIAgent agentStoreFalse = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) - .AsIChatClientWithStoredOutputDisabled() + .GetResponsesClient() + .AsIChatClientWithStoredOutputDisabled(model: deploymentName) .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs index 611f3f9a9a..baa6677a4f 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_OpenAIResponses/Program.cs @@ -11,8 +11,8 @@ var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt AIAgent agent = new OpenAIClient( apiKey) - .GetResponsesClient(model) - .AsAIAgent(instructions: "You are good at telling jokes.", name: "Joker"); + .GetResponsesClient() + .AsAIAgent(model: model, instructions: "You are good at telling jokes.", name: "Joker"); // Invoke the agent and output the text result. Console.WriteLine(await agent.RunAsync("Tell me a joke about a pirate.")); diff --git a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs index 290c3f9b6b..9b0a4b4f99 100644 --- a/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs +++ b/dotnet/samples/02-agents/AgentSkills/Agent_Step01_BasicSkills/Program.cs @@ -23,7 +23,7 @@ var skillsProvider = new FileAgentSkillsProvider(skillPath: Path.Combine(AppCont // --- Agent Setup --- AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) + .GetResponsesClient() .AsAIAgent(new ChatClientAgentOptions { Name = "SkillsAgent", @@ -32,7 +32,8 @@ AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredent Instructions = "You are a helpful assistant.", }, AIContextProviders = [skillsProvider], - }); + }, + model: deploymentName); // --- Example 1: Expense policy question (loads FAQ resource) --- Console.WriteLine("Example 1: Checking expense policy FAQ"); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs index d13d0d5346..12e30dc203 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs @@ -10,8 +10,8 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new I var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-5"; var client = new OpenAIClient(apiKey) - .GetResponsesClient(model) - .AsIChatClient().AsBuilder() + .GetResponsesClient() + .AsIChatClient(model).AsBuilder() .ConfigureOptions(o => { o.Reasoning = new() diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs index 196bd64922..4deb134fd7 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/OpenAIResponseClientAgent.cs @@ -20,19 +20,21 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent /// Optional instructions for the agent. /// Optional name for the agent. /// Optional description for the agent. + /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). /// Optional instance of public OpenAIResponseClientAgent( ResponsesClient client, string? instructions = null, string? name = null, string? description = null, + string? model = null, ILoggerFactory? loggerFactory = null) : this(client, new() { Name = name, Description = description, ChatOptions = new ChatOptions() { Instructions = instructions }, - }, loggerFactory) + }, model, loggerFactory) { } @@ -41,10 +43,11 @@ public class OpenAIResponseClientAgent : DelegatingAIAgent /// /// Instance of /// Options to create the agent. + /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). /// Optional instance of public OpenAIResponseClientAgent( - ResponsesClient client, ChatClientAgentOptions options, ILoggerFactory? loggerFactory = null) : - base(new ChatClientAgent((client ?? throw new ArgumentNullException(nameof(client))).AsIChatClient(), options, loggerFactory)) + ResponsesClient client, ChatClientAgentOptions options, string? model = null, ILoggerFactory? loggerFactory = null) : + base(new ChatClientAgent((client ?? throw new ArgumentNullException(nameof(client))).AsIChatClient(model), options, loggerFactory)) { } diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs index 8004770c21..dbd11ce3c6 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step04_CreateFromOpenAIResponseClient/Program.cs @@ -10,10 +10,10 @@ var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new I var model = Environment.GetEnvironmentVariable("OPENAI_CHAT_MODEL_NAME") ?? "gpt-4o-mini"; // Create a ResponsesClient directly from OpenAIClient -ResponsesClient responseClient = new OpenAIClient(apiKey).GetResponsesClient(model); +ResponsesClient responseClient = new OpenAIClient(apiKey).GetResponsesClient(); // Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent -OpenAIResponseClientAgent agent = new(responseClient, instructions: "You are good at telling jokes.", name: "Joker"); +OpenAIResponseClientAgent agent = new(responseClient, instructions: "You are good at telling jokes.", name: "Joker", model: model); ResponseItem userMessage = ResponseItem.CreateUserMessageItem("Tell me a joke about a pirate."); diff --git a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs index 921acbad0d..603f8b8e7b 100644 --- a/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs +++ b/dotnet/samples/02-agents/AgentWithOpenAI/Agent_OpenAI_Step05_Conversation/Program.cs @@ -22,7 +22,7 @@ OpenAIClient openAIClient = new(apiKey); ConversationClient conversationClient = openAIClient.GetConversationClient(); // Create an agent directly from the ResponsesClient using OpenAIResponseClientAgent -ChatClientAgent agent = new(openAIClient.GetResponsesClient(model).AsIChatClient(), instructions: "You are a helpful assistant.", name: "ConversationAgent"); +ChatClientAgent agent = new(openAIClient.GetResponsesClient().AsIChatClient(model), instructions: "You are a helpful assistant.", name: "ConversationAgent"); ClientResult createConversationResult = await conversationClient.CreateConversationAsync(BinaryContent.Create(BinaryData.FromString("{}"))); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs index 5bdfc9421c..8ff4181a51 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step01_UsingFunctionToolsWithApprovals/Program.cs @@ -36,11 +36,11 @@ AIAgent agent = new AzureOpenAIClient( // For simplicity, we are assuming here that only function approvals are pending. AgentSession session = await agent.CreateSessionAsync(); AgentResponse response = await agent.RunAsync("What is the weather like in Amsterdam?", session); -List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); +List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); // For streaming use: // var updates = await agent.RunStreamingAsync("What is the weather like in Amsterdam?", session).ToListAsync(); -// approvalRequests = updates.SelectMany(x => x.Contents).OfType().ToList(); +// approvalRequests = updates.SelectMany(x => x.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -48,18 +48,18 @@ while (approvalRequests.Count > 0) List userInputResponses = approvalRequests .ConvertAll(functionApprovalRequest => { - Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); }); // Pass the user input responses back to the agent for further processing. response = await agent.RunAsync(userInputResponses, session); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); // For streaming use: // updates = await agent.RunStreamingAsync(userInputResponses, session).ToListAsync(); - // approvalRequests = updates.SelectMany(x => x.Contents).OfType().ToList(); + // approvalRequests = updates.SelectMany(x => x.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs index 5d9c70a5fd..b568ef5867 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step10_BackgroundResponsesWithToolsAndPersistence/Program.cs @@ -25,8 +25,9 @@ var stateStore = new Dictionary(); AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) + .GetResponsesClient() .AsAIAgent( + model: deploymentName, name: "SpaceNovelWriter", instructions: "You are a space novel writer. Always research relevant facts and generate character profiles for the main characters before writing novels." + "Write complete chapters without asking for approval or feedback. Do not ask the user about tone, style, pace, or format preferences - just write the novel based on the request.", diff --git a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs index 09cd540378..18969ed66e 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/Program.cs @@ -246,7 +246,7 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -255,13 +255,13 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable { - Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); }); response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } return response; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs index 62db550556..f474b938a6 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step14_BackgroundResponses/Program.cs @@ -16,8 +16,8 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) - .AsAIAgent(); + .GetResponsesClient() + .AsAIAgent(model: deploymentName); // Enable background responses (only supported by OpenAI Responses at this time). AgentRunOptions options = new() { AllowBackgroundResponses = true }; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs index 72676bed45..f4521d8898 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.1_Basics/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to create and use AI agents with Azure Foundry Agents as the backend. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs index dd5db03b15..0bc17aff0a 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step01.2_Running/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs index 1ac51c30ad..7bf12094fc 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step02_MultiturnConversation/Program.cs @@ -2,8 +2,9 @@ // This sample shows how to create and use a simple AI agent with a multi-turn conversation. +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs index f33fae35f4..08051a500e 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step04_UsingFunctionToolsWithApprovals/Program.cs @@ -40,7 +40,7 @@ AgentResponse response = await agent.RunAsync("What is the weather like in Amste // Check if there are any approval requests. // For simplicity, we are assuming here that only function approvals are pending. -List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); +List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -48,7 +48,7 @@ while (approvalRequests.Count > 0) List userInputMessages = approvalRequests .ConvertAll(functionApprovalRequest => { - Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); }); @@ -56,7 +56,7 @@ while (approvalRequests.Count > 0) // Pass the user input responses back to the agent for further processing. response = await agent.RunAsync(userInputMessages, session); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs index 7ea6bc88a3..824e1507b3 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step12_Middleware/Program.cs @@ -197,7 +197,7 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -206,14 +206,14 @@ async Task ConsolePromptingApprovalMiddleware(IEnumerable { - Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {functionApprovalRequest.FunctionCall.Name}"); + Console.WriteLine($"The agent would like to invoke the following function, please reply Y to approve: Name {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}"); bool approved = Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved)]); }); response = await innerAgent.RunAsync(response.Messages, session, options, cancellationToken); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } return response; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs index 854d317495..5a27daed12 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step14_CodeInterpreter/Program.cs @@ -4,7 +4,7 @@ using System.Text; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs index 1c5510218a..7f6382d085 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step15_ComputerUse/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use Computer Use Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs index 36f28c2387..5371903a9f 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step16_FileSearch/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use File Search Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs index 2ee5a94458..ebf66e6c2c 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step17_OpenAPITools/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use OpenAPI Tools with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using OpenAI.Responses; @@ -72,7 +72,7 @@ const string CountriesOpenApiSpec = """ AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Create the OpenAPI function definition -var openApiFunction = new OpenAPIFunctionDefinition( +var openApiFunction = new OpenApiFunctionDefinition( "get_countries", BinaryData.FromString(CountriesOpenApiSpec), new OpenAPIAnonymousAuthenticationDetails()) diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs index 365bf6ed08..98ea576226 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step18_BingCustomSearch/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use Bing Custom Search Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using OpenAI.Responses; @@ -25,7 +25,7 @@ const string AgentInstructions = """ AIProjectClient aiProjectClient = new(new Uri(endpoint), new DefaultAzureCredential()); // Bing Custom Search tool parameters shared by both options -BingCustomSearchToolParameters bingCustomSearchToolParameters = new([ +BingCustomSearchToolOptions bingCustomSearchToolParameters = new([ new BingCustomSearchConfiguration(connectionId, instanceName) ]); diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs index 6d1daf85df..ad6a08abaa 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step19_SharePoint/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use SharePoint Grounding Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs index 2f13c2c30c..e5ab205f68 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step20_MicrosoftFabric/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use Microsoft Fabric Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using OpenAI.Responses; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs index 1ac312ddae..c116a975e1 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step21_WebSearch/Program.cs @@ -3,7 +3,7 @@ // This sample shows how to use the Responses API Web Search Tool with AI Agents. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj index a1ccdfcd3a..d83a9d9202 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/FoundryAgents_Step22_MemorySearch.csproj @@ -13,7 +13,6 @@ - diff --git a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs index 1f6b0f2ddc..60452b7d19 100644 --- a/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs +++ b/dotnet/samples/02-agents/FoundryAgents/FoundryAgents_Step22_MemorySearch/Program.cs @@ -4,8 +4,9 @@ // The Memory Search Tool enables agents to recall information from previous conversations, // supporting user profile persistence and chat summaries across sessions. +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using OpenAI.Responses; @@ -36,7 +37,7 @@ AIProjectClient aiProjectClient = new(new Uri(endpoint), credential); await EnsureMemoryStoreAsync(); // Create the Memory Search tool configuration -MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelay = 0 }; +MemorySearchPreviewTool memorySearchTool = new(memoryStoreName, userScope) { UpdateDelayInSecs = 0 }; // Create agent using Option 1 (MEAI) or Option 2 (Native SDK) AIAgent agent = await CreateAgentWithMEAI(); @@ -128,8 +129,8 @@ async Task EnsureMemoryStoreAsync() MemoryUpdateResult updateResult = await aiProjectClient.MemoryStores.WaitForMemoriesUpdateAsync( memoryStoreName: memoryStoreName, - options: memoryOptions, - pollingInterval: 500); + pollingInterval: 500, + options: memoryOptions); if (updateResult.Status == MemoryStoreUpdateStatus.Failed) { diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index 99d26c103d..e34c3d932e 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -81,7 +81,7 @@ AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAs // For simplicity, we are assuming here that only mcp tool approvals are pending. AgentSession sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync(); AgentResponse response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval); -List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); +List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -89,11 +89,12 @@ while (approvalRequests.Count > 0) List userInputResponses = approvalRequests .ConvertAll(approvalRequest => { + McpServerToolCallContent mcpToolCall = (McpServerToolCallContent)approvalRequest.ToolCall!; Console.WriteLine($""" The agent would like to invoke the following MCP Tool, please reply Y to approve. - ServerName: {approvalRequest.ToolCall.ServerName} - Name: {approvalRequest.ToolCall.ToolName} - Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} + ServerName: {mcpToolCall.ServerName} + Name: {mcpToolCall.Name} + Arguments: {string.Join(", ", mcpToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} """); return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); }); @@ -101,7 +102,7 @@ while (approvalRequests.Count > 0) // Pass the user input responses back to the agent for further processing. response = await agentWithRequiredApproval.RunAsync(userInputResponses, sessionWithRequiredApproval); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs index 194952e68a..f8715e4543 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/ResponseAgent_Hosted_MCP/Program.cs @@ -33,8 +33,9 @@ var mcpTool = new HostedMcpServerTool( AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) + .GetResponsesClient() .AsAIAgent( + model: deploymentName, instructions: "You answer questions by searching the Microsoft Learn content only.", name: "MicrosoftLearnAgent", tools: [mcpTool]); @@ -60,8 +61,9 @@ var mcpToolWithApproval = new HostedMcpServerTool( AIAgent agentWithRequiredApproval = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) + .GetResponsesClient() .AsAIAgent( + model: deploymentName, instructions: "You answer questions by searching the Microsoft Learn content only.", name: "MicrosoftLearnAgentWithApproval", tools: [mcpToolWithApproval]); @@ -70,7 +72,7 @@ AIAgent agentWithRequiredApproval = new AzureOpenAIClient( // For simplicity, we are assuming here that only mcp tool approvals are pending. AgentSession sessionWithRequiredApproval = await agentWithRequiredApproval.CreateSessionAsync(); AgentResponse response = await agentWithRequiredApproval.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", sessionWithRequiredApproval); -List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); +List approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); while (approvalRequests.Count > 0) { @@ -78,11 +80,12 @@ while (approvalRequests.Count > 0) List userInputResponses = approvalRequests .ConvertAll(approvalRequest => { + McpServerToolCallContent mcpToolCall = (McpServerToolCallContent)approvalRequest.ToolCall!; Console.WriteLine($""" The agent would like to invoke the following MCP Tool, please reply Y to approve. - ServerName: {approvalRequest.ToolCall.ServerName} - Name: {approvalRequest.ToolCall.ToolName} - Arguments: {string.Join(", ", approvalRequest.ToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} + ServerName: {mcpToolCall.ServerName} + Name: {mcpToolCall.Name} + Arguments: {string.Join(", ", mcpToolCall.Arguments?.Select(x => $"{x.Key}: {x.Value}") ?? [])} """); return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(Console.ReadLine()?.Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false)]); }); @@ -90,7 +93,7 @@ while (approvalRequests.Count > 0) // Pass the user input responses back to the agent for further processing. response = await agentWithRequiredApproval.RunAsync(userInputResponses, sessionWithRequiredApproval); - approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); + approvalRequests = response.Messages.SelectMany(m => m.Contents).OfType().ToList(); } Console.WriteLine($"\nAgent: {response}"); diff --git a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs index 076e764ea8..a8d42b5342 100644 --- a/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs +++ b/dotnet/samples/03-workflows/Agents/GroupChatToolApproval/Program.cs @@ -17,7 +17,7 @@ // // Demonstrate: // - Using custom GroupChatManager with agents that have approval-required tools. -// - Handling FunctionApprovalRequestContent in group chat scenarios. +// - Handling ToolApprovalRequestContent in group chat scenarios. // - Multi-round group chat with tool approval interruption and resumption. using System.ComponentModel; @@ -101,16 +101,16 @@ public static class Program { case RequestInfoEvent e: { - if (e.Request.TryGetDataAs(out FunctionApprovalRequestContent? approvalRequestContent)) + if (e.Request.TryGetDataAs(out ToolApprovalRequestContent? approvalRequestContent)) { Console.WriteLine(); Console.WriteLine($"[APPROVAL REQUIRED] From agent: {e.Request.PortInfo.PortId}"); - Console.WriteLine($" Tool: {approvalRequestContent.FunctionCall.Name}"); - Console.WriteLine($" Arguments: {JsonSerializer.Serialize(approvalRequestContent.FunctionCall.Arguments)}"); + Console.WriteLine($" Tool: {((FunctionCallContent)approvalRequestContent.ToolCall).Name}"); + Console.WriteLine($" Arguments: {JsonSerializer.Serialize(((FunctionCallContent)approvalRequestContent.ToolCall).Arguments)}"); Console.WriteLine(); // Approve the tool call request - Console.WriteLine($"Tool: {approvalRequestContent.FunctionCall.Name} approved"); + Console.WriteLine($"Tool: {((FunctionCallContent)approvalRequestContent.ToolCall).Name} approved"); await run.SendResponseAsync(e.Request.CreateResponse(approvalRequestContent.CreateResponse(approved: true))); } diff --git a/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs b/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs index b5df45a399..5b0458f23d 100644 --- a/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/CustomerSupport/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; diff --git a/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs b/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs index 98d75d250b..e415c7aad0 100644 --- a/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/DeepResearch/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.Configuration; using OpenAI.Responses; @@ -275,7 +275,7 @@ internal sealed class Program Tools = { AgentTool.CreateOpenApiTool( - new OpenAPIFunctionDefinition( + new OpenApiFunctionDefinition( "weather-forecast", BinaryData.FromString(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "wttr.json"))), new OpenAPIAnonymousAuthenticationDetails())) diff --git a/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs b/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs index 8218e7c057..a1bd9de8f9 100644 --- a/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/FunctionTools/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; diff --git a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs index 81e2abbafe..5936aaf82f 100644 --- a/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/HostedWorkflow/Program.cs @@ -3,8 +3,9 @@ // Uncomment this to enable JSON checkpointing to the local file system. //#define CHECKPOINT_JSON +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; diff --git a/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs b/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs index 65a365b143..0a6f99f920 100644 --- a/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InputArguments/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.Configuration; using OpenAI.Responses; diff --git a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs index fb20764977..8875d204f2 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeFunctionTool/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; diff --git a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs index 926afcfc3c..61ce1afc70 100644 --- a/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/InvokeMcpTool/Program.cs @@ -5,7 +5,7 @@ // invoked to perform specific tasks, like searching documentation or executing operations. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Core; using Azure.Identity; using Microsoft.Agents.AI.Workflows.Declarative.Mcp; diff --git a/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs b/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs index 308303c162..5d73edd26d 100644 --- a/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/Marketing/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.Configuration; using Shared.Foundry; diff --git a/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs b/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs index 28523c031e..4f1d31a2ea 100644 --- a/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/StudentTeacher/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.Configuration; using Shared.Foundry; diff --git a/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs b/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs index 544974e096..9e9bd65b6b 100644 --- a/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs +++ b/dotnet/samples/03-workflows/Declarative/ToolApproval/Program.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Identity; using Microsoft.Extensions.Configuration; using OpenAI.Responses; diff --git a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs index 0d2470762e..839c8e75a1 100644 --- a/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs +++ b/dotnet/samples/05-end-to-end/AgentWebChat/AgentWebChat.Web/OpenAIResponsesAgentClient.cs @@ -27,7 +27,7 @@ internal sealed class OpenAIResponsesAgentClient(HttpClient httpClient) : AgentC Transport = new HttpClientPipelineTransport(httpClient) }; - var openAiClient = new ResponsesClient(model: agentName, credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(); + var openAiClient = new ResponsesClient(credential: new ApiKeyCredential("dummy-key"), options: options).AsIChatClient(agentName); var chatOptions = new ChatOptions() { ConversationId = sessionId diff --git a/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs b/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs index fc0974c5bd..33e7001a51 100644 --- a/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs +++ b/dotnet/samples/05-end-to-end/AgentWithPurview/Program.cs @@ -30,8 +30,8 @@ TokenCredential browserCredential = new InteractiveBrowserCredential( using IChatClient client = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) - .AsIChatClient() + .GetResponsesClient() + .AsIChatClient(deploymentName) .AsBuilder() .WithPurview(browserCredential, new PurviewSettings("Agent Framework Test App")) .Build(); diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj index 1398a60228..1afc7a7cec 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentThreadAndHITL/AgentThreadAndHITL.csproj @@ -37,7 +37,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj index e854cfcd40..c0e14e74b8 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/AgentWithHostedMCP.csproj @@ -36,9 +36,10 @@ - + + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs index b7b610b663..8559269dff 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithHostedMCP/Program.cs @@ -31,7 +31,8 @@ AITool mcpTool = new HostedMcpServerTool(serverName: "microsoft_learn", serverAd AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), new DefaultAzureCredential()) - .GetResponsesClient(deploymentName) + .GetResponsesClient() + .AsIChatClient(deploymentName) .AsAIAgent( instructions: "You answer questions by searching the Microsoft Learn content only.", name: "MicrosoftLearnAgent", diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj index 975333e584..ccda3156e5 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithLocalTools/AgentWithLocalTools.csproj @@ -38,7 +38,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj index 32e00f832b..19e5015912 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTextSearchRag/AgentWithTextSearchRag.csproj @@ -36,7 +36,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj index 959cca1db5..19e5015912 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentWithTools/AgentWithTools.csproj @@ -36,7 +36,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj index 56a55a428d..3b3af40664 100644 --- a/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj +++ b/dotnet/samples/05-end-to-end/HostedAgents/AgentsInWorkflows/AgentsInWorkflows.csproj @@ -36,7 +36,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/M365Agent/AFAgentApplication.cs b/dotnet/samples/05-end-to-end/M365Agent/AFAgentApplication.cs index 7e58819a65..502c57204e 100644 --- a/dotnet/samples/05-end-to-end/M365Agent/AFAgentApplication.cs +++ b/dotnet/samples/05-end-to-end/M365Agent/AFAgentApplication.cs @@ -122,7 +122,7 @@ internal sealed class AFAgentApplication : AgentApplication && valueElement.GetProperty("requestJson") is JsonElement requestJsonElement && requestJsonElement.ValueKind == JsonValueKind.String) { - var requestContent = JsonSerializer.Deserialize(requestJsonElement.GetString()!, JsonUtilities.DefaultOptions); + var requestContent = JsonSerializer.Deserialize(requestJsonElement.GetString()!, JsonUtilities.DefaultOptions); return new ChatMessage(ChatRole.User, [requestContent!.CreateResponse(approvedJsonElement.ValueKind == JsonValueKind.True)]); } @@ -138,7 +138,7 @@ internal sealed class AFAgentApplication : AgentApplication /// The list of to which the adaptive cards will be added. private static void HandleUserInputRequests(AgentResponse response, ref List? attachments) { - foreach (FunctionApprovalRequestContent functionApprovalRequest in response.Messages.SelectMany(m => m.Contents).OfType()) + foreach (ToolApprovalRequestContent functionApprovalRequest in response.Messages.SelectMany(m => m.Contents).OfType()) { var functionApprovalRequestJson = JsonSerializer.Serialize(functionApprovalRequest, JsonUtilities.DefaultOptions); @@ -152,7 +152,7 @@ internal sealed class AFAgentApplication : AgentApplication }); card.Body.Add(new AdaptiveTextBlock { - Text = $"Function: {functionApprovalRequest.FunctionCall.Name}" + Text = $"Function: {((FunctionCallContent)functionApprovalRequest.ToolCall).Name}" }); card.Body.Add(new AdaptiveActionSet() { diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj index 31785a8fa9..bb02f3065a 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj @@ -20,6 +20,8 @@ Microsoft Agent Framework AzureAI Persistent Agents Provides Microsoft Agent Framework support for Azure AI Persistent Agents. + + false diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md new file mode 100644 index 0000000000..a01debc5ca --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/README.md @@ -0,0 +1,19 @@ +# Microsoft.Agents.AI.AzureAI.Persistent + +Provides integration between the Microsoft Agent Framework and Azure AI Agents Persistent (`Azure.AI.Agents.Persistent`). + +## ⚠️ Known Compatibility Limitation + +The underlying `Azure.AI.Agents.Persistent` package (currently 1.2.0-beta.9) targets `Microsoft.Extensions.AI.Abstractions` 10.1.x and references types that were renamed in 10.4.0 (e.g., `McpServerToolApprovalResponseContent` → `ToolApprovalResponseContent`). This causes `TypeLoadException` at runtime when used with ME.AI 10.4.0+. + +**Compatible versions:** + +| Package | Compatible Version | +|---|---| +| `Azure.AI.Agents.Persistent` | 1.2.0-beta.9 (targets ME.AI 10.1.x) | +| `Microsoft.Extensions.AI.Abstractions` | ≤ 10.3.0 | +| `OpenAI` | ≤ 2.8.0 | + +**Resolution:** An updated version of `Azure.AI.Agents.Persistent` targeting ME.AI 10.4.0+ is expected in 1.2.0-beta.10. The upstream fix is tracked in [Azure/azure-sdk-for-net#56929](https://github.com/Azure/azure-sdk-for-net/pull/56929). + +**Tracking issue:** [microsoft/agent-framework#4769](https://github.com/microsoft/agent-framework/issues/4769) diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs index 51ddf0054c..32bb08674b 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClient.cs @@ -2,8 +2,9 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; @@ -57,7 +58,7 @@ internal sealed class AzureAIProjectChatClient : DelegatingChatClient /// The provided should be decorated with a for proper functionality. /// internal AzureAIProjectChatClient(AIProjectClient aiProjectClient, AgentRecord agentRecord, ChatOptions? chatOptions) - : this(aiProjectClient, Throw.IfNull(agentRecord).Versions.Latest, chatOptions) + : this(aiProjectClient, Throw.IfNull(agentRecord).GetLatestVersion(), chatOptions) { this._agentRecord = agentRecord; } diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index 5d2c67695f..b129f4b1f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -9,7 +9,8 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Extensions.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; using Microsoft.Agents.AI.AzureAI; using Microsoft.Extensions.AI; @@ -189,7 +190,7 @@ public static partial class AzureAIProjectChatClientExtensions ThrowIfInvalidAgentName(options.Name); AgentRecord agentRecord = await GetAgentRecordByNameAsync(aiProjectClient, options.Name, cancellationToken).ConfigureAwait(false); - var agentVersion = agentRecord.Versions.Latest; + var agentVersion = agentRecord.GetLatestVersion(); var agentOptions = CreateChatClientAgentOptions(agentVersion, options, requireInvocableTools: !options.UseProvidedChatClientAsIs); @@ -361,7 +362,7 @@ public static partial class AzureAIProjectChatClientExtensions { ClientResult protocolResponse = await aiProjectClient.Agents.GetAgentAsync(agentName, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); var rawResponse = protocolResponse.GetRawResponse(); - AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + AgentRecord? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); return result ?? throw new InvalidOperationException($"Agent with name '{agentName}' not found."); } @@ -370,11 +371,11 @@ public static partial class AzureAIProjectChatClientExtensions /// private static async Task CreateAgentVersionWithProtocolAsync(AIProjectClient aiProjectClient, string agentName, AgentVersionCreationOptions creationOptions, CancellationToken cancellationToken) { - BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsContext.Default); + BinaryData serializedOptions = ModelReaderWriter.Write(creationOptions, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); BinaryContent content = BinaryContent.Create(serializedOptions); ClientResult protocolResponse = await aiProjectClient.Agents.CreateAgentVersionAsync(agentName, content, foundryFeatures: null, cancellationToken.ToRequestOptions(false)).ConfigureAwait(false); var rawResponse = protocolResponse.GetRawResponse(); - AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsOpenAIContext.Default); + AgentVersion? result = ModelReaderWriter.Read(rawResponse.Content, s_modelWriterOptionsWire, AzureAIProjectsAgentsContext.Default); return result ?? throw new InvalidOperationException($"Failed to create agent version for agent '{agentName}'."); } @@ -485,7 +486,7 @@ public static partial class AzureAIProjectChatClientExtensions => AsChatClientAgent( AIProjectClient, agentRecord, - CreateChatClientAgentOptions(agentRecord.Versions.Latest, new ChatOptions() { Tools = tools }, requireInvocableTools), + CreateChatClientAgentOptions(agentRecord.GetLatestVersion(), new ChatOptions() { Tools = tools }, requireInvocableTools), clientFactory, services); diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj index 2fde79e32b..0cd8690126 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/Microsoft.Agents.AI.AzureAI.csproj @@ -15,7 +15,6 @@ - diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs index f4c1e3c7a0..ac63237d56 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs @@ -196,8 +196,8 @@ internal static class AgentResponseUpdateExtensions TextReasoningContent => new TextReasoningContentEventGenerator(context.IdGenerator, seq, outputIndex), FunctionCallContent => new FunctionCallEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions), FunctionResultContent => new FunctionResultEventGenerator(context.IdGenerator, seq, outputIndex), - FunctionApprovalRequestContent => new FunctionApprovalRequestEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions), - FunctionApprovalResponseContent => new FunctionApprovalResponseEventGenerator(context.IdGenerator, seq, outputIndex), + ToolApprovalRequestContent => new ToolApprovalRequestEventGenerator(context.IdGenerator, seq, outputIndex, context.JsonSerializerOptions), + ToolApprovalResponseContent => new ToolApprovalResponseEventGenerator(context.IdGenerator, seq, outputIndex), ErrorContent => new ErrorContentEventGenerator(context.IdGenerator, seq, outputIndex), UriContent uriContent when uriContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex), DataContent dataContent when dataContent.HasTopLevelMediaType("image") => new ImageContentEventGenerator(context.IdGenerator, seq, outputIndex), diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs index 4e565b0784..f68fa12e4d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalRequestEventGenerator.cs @@ -12,33 +12,37 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Streaming; /// A generator for streaming events from function approval request content. /// This is a non-standard DevUI extension for human-in-the-loop scenarios. /// -internal sealed class FunctionApprovalRequestEventGenerator( +internal sealed class ToolApprovalRequestEventGenerator( IdGenerator idGenerator, SequenceNumber seq, int outputIndex, JsonSerializerOptions jsonSerializerOptions) : StreamingEventGenerator { - public override bool IsSupported(AIContent content) => content is FunctionApprovalRequestContent; + public override bool IsSupported(AIContent content) => content is ToolApprovalRequestContent; public override IEnumerable ProcessContent(AIContent content) { - if (content is not FunctionApprovalRequestContent approvalRequest) + if (content is not ToolApprovalRequestContent approvalRequest) { - throw new InvalidOperationException("FunctionApprovalRequestEventGenerator only supports FunctionApprovalRequestContent."); + throw new InvalidOperationException("ToolApprovalRequestEventGenerator only supports ToolApprovalRequestContent."); } + if (approvalRequest.ToolCall is not FunctionCallContent functionCall) + { + yield break; + } yield return new StreamingFunctionApprovalRequested { SequenceNumber = seq.Increment(), OutputIndex = outputIndex, - RequestId = approvalRequest.Id, + RequestId = approvalRequest.RequestId, ItemId = idGenerator.GenerateMessageId(), FunctionCall = new FunctionCallInfo { - Id = approvalRequest.FunctionCall.CallId, - Name = approvalRequest.FunctionCall.Name, + Id = functionCall.CallId, + Name = functionCall.Name, Arguments = JsonSerializer.SerializeToElement( - approvalRequest.FunctionCall.Arguments, + functionCall.Arguments, jsonSerializerOptions.GetTypeInfo(typeof(IDictionary))) } }; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs index ab4af8f408..df1379cfbb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/Streaming/FunctionApprovalResponseEventGenerator.cs @@ -11,25 +11,25 @@ namespace Microsoft.Agents.AI.Hosting.OpenAI.Responses.Streaming; /// A generator for streaming events from function approval response content. /// This is a non-standard DevUI extension for human-in-the-loop scenarios. /// -internal sealed class FunctionApprovalResponseEventGenerator( +internal sealed class ToolApprovalResponseEventGenerator( IdGenerator idGenerator, SequenceNumber seq, int outputIndex) : StreamingEventGenerator { - public override bool IsSupported(AIContent content) => content is FunctionApprovalResponseContent; + public override bool IsSupported(AIContent content) => content is ToolApprovalResponseContent; public override IEnumerable ProcessContent(AIContent content) { - if (content is not FunctionApprovalResponseContent approvalResponse) + if (content is not ToolApprovalResponseContent approvalResponse) { - throw new InvalidOperationException("FunctionApprovalResponseEventGenerator only supports FunctionApprovalResponseContent."); + throw new InvalidOperationException("ToolApprovalResponseEventGenerator only supports ToolApprovalResponseContent."); } yield return new StreamingFunctionApprovalResponded { SequenceNumber = seq.Increment(), OutputIndex = outputIndex, - RequestId = approvalResponse.Id, + RequestId = approvalResponse.RequestId, Approved = approvalResponse.Approved, ItemId = idGenerator.GenerateMessageId() }; diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs index db0c7a8673..34c07ea73f 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/ChatClient/AsyncStreamingChatCompletionUpdateCollectionResult.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable OPENAI001 // Experimental OpenAI features + using System.ClientModel; using OpenAI.Chat; diff --git a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs index 09046e1822..5aee8eb046 100644 --- a/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.OpenAI/Extensions/OpenAIResponseClientExtensions.cs @@ -26,6 +26,7 @@ public static class OpenAIResponseClientExtensions /// Creates an AI agent from an using the OpenAI Response API. /// /// The to use for the agent. + /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). /// Optional system instructions that define the agent's behavior and personality. /// Optional name for the agent for identification purposes. /// Optional description of the agent's capabilities and purpose. @@ -37,6 +38,7 @@ public static class OpenAIResponseClientExtensions /// Thrown when is . public static ChatClientAgent AsAIAgent( this ResponsesClient client, + string? model = null, string? instructions = null, string? name = null, string? description = null, @@ -58,6 +60,7 @@ public static class OpenAIResponseClientExtensions Tools = tools, } }, + model, clientFactory, loggerFactory, services); @@ -68,6 +71,7 @@ public static class OpenAIResponseClientExtensions /// /// The to use for the agent. /// Full set of options to configure the agent. + /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). /// Provides a way to customize the creation of the underlying used by the agent. /// Optional logger factory for enabling logging within the agent. /// An optional to use for resolving services required by the instances being invoked. @@ -76,6 +80,7 @@ public static class OpenAIResponseClientExtensions public static ChatClientAgent AsAIAgent( this ResponsesClient client, ChatClientAgentOptions options, + string? model = null, Func? clientFactory = null, ILoggerFactory? loggerFactory = null, IServiceProvider? services = null) @@ -83,7 +88,7 @@ public static class OpenAIResponseClientExtensions Throw.IfNull(client); Throw.IfNull(options); - var chatClient = client.AsIChatClient(); + var chatClient = client.AsIChatClient(model); if (clientFactory is not null) { @@ -100,6 +105,7 @@ public static class OpenAIResponseClientExtensions /// This corresponds to setting the "store" property in the JSON representation to false. /// /// The client. + /// Optional default model ID to use for requests. Required when using a plain (not via Azure OpenAI). /// /// Includes an encrypted version of reasoning tokens in reasoning item outputs. /// This enables reasoning items to be used in multi-turn conversations when using the Responses API statelessly @@ -109,10 +115,10 @@ public static class OpenAIResponseClientExtensions /// An that can be used to converse via the that does not store responses for later retrieval. /// is . [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] - public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient, bool includeReasoningEncryptedContent = true) + public static IChatClient AsIChatClientWithStoredOutputDisabled(this ResponsesClient responseClient, string? model = null, bool includeReasoningEncryptedContent = true) { return Throw.IfNull(responseClient) - .AsIChatClient() + .AsIChatClient(model) .AsBuilder() .ConfigureOptions(x => x.RawRepresentationFactory = _ => includeReasoningEncryptedContent ? new CreateResponseOptions() { StoredOutputEnabled = false, IncludedProperties = { IncludedResponseProperty.ReasoningEncryptedContent } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs index 9f909ad84e..bfc7bd36ff 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.AzureAI/AzureAgentProvider.cs @@ -10,8 +10,9 @@ using System.Runtime.CompilerServices; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Azure.Core; using Microsoft.Extensions.AI; using OpenAI.Responses; @@ -149,7 +150,7 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj agentName, cancellationToken).ConfigureAwait(false); - targetAgent = agentRecord.Versions.Latest; + targetAgent = agentRecord.GetLatestVersion(); } else { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 107f4f0260..681cd5dc85 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -185,8 +185,8 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable private static void PopulateResultContent(McpServerToolResultContent resultContent, CallToolResult result) { - // Ensure Output list is initialized - resultContent.Output ??= []; + // Ensure Outputs list is initialized + resultContent.Outputs ??= []; if (result.IsError == true) { @@ -203,7 +203,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable } } - resultContent.Output.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}")); + resultContent.Outputs.Add(new TextContent($"Error: {errorText ?? "Unknown error from MCP Server call"}")); return; } @@ -218,7 +218,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable AIContent content = ConvertContentBlock(block); if (content is not null) { - resultContent.Output.Add(content); + resultContent.Outputs.Add(content); } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs index c8cde902fa..24653af0f2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeAzureAgentExecutor.cs @@ -150,7 +150,7 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA foreach (ChatMessage responseMessage in agentResponse.Messages) { - if (responseMessage.Contents.Any(content => content is UserInputRequestContent)) + if (responseMessage.Contents.Any(content => content is ToolApprovalRequestContent)) { yield return responseMessage; continue; diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs index 0e95bebe63..baa6f9c6b8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeFunctionToolExecutor.cs @@ -68,7 +68,7 @@ internal sealed class InvokeFunctionToolExecutor( // If approval is required, add user input request content if (requireApproval) { - requestMessage.Contents.Add(new FunctionApprovalRequestContent(this.Id, functionCall)); + requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall)); } AgentResponse agentResponse = new([requestMessage]); diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 45929f20f7..b1d9a44269 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -85,7 +85,7 @@ internal sealed class InvokeMcpToolExecutor( toolCall.AdditionalProperties.Add(headers); } - McpServerToolApprovalRequestContent approvalRequest = new(this.Id, toolCall); + ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall); ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]); AgentResponse agentResponse = new([requestMessage]); @@ -127,11 +127,10 @@ internal sealed class InvokeMcpToolExecutor( ExternalInputResponse response, CancellationToken cancellationToken) { - // Check for approval response - McpServerToolApprovalResponseContent? approvalResponse = response.Messages + ToolApprovalResponseContent? approvalResponse = response.Messages .SelectMany(m => m.Contents) - .OfType() - .FirstOrDefault(r => r.Id == this.Id); + .OfType() + .FirstOrDefault(r => r.RequestId == this.Id); if (approvalResponse?.Approved != true) { @@ -174,7 +173,7 @@ internal sealed class InvokeMcpToolExecutor( string? conversationId = this.GetConversationId(); await this.AssignResultAsync(context, resultContent).ConfigureAwait(false); - ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Output); + ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Outputs); // Store messages if output path is configured if (this.Model.Output?.Messages is not null) @@ -192,20 +191,20 @@ internal sealed class InvokeMcpToolExecutor( // Add messages to conversation if conversationId is provided if (conversationId is not null) { - ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Output); + ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Outputs); await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false); } } private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult) { - if (this.Model.Output?.Result is null || toolResult.Output is null || toolResult.Output.Count == 0) + if (this.Model.Output?.Result is null || toolResult.Outputs is null || toolResult.Outputs.Count == 0) { return; } List parsedResults = []; - foreach (AIContent resultContent in toolResult.Output) + foreach (AIContent resultContent in toolResult.Outputs) { object? resultValue = resultContent switch { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs index 623981e204..c981b5d801 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/AIAgentHostOptions.cs @@ -21,7 +21,7 @@ public sealed class AIAgentHostOptions public bool EmitAgentResponseEvents { get; set; } /// - /// Gets or sets a value indicating whether should be intercepted and sent + /// Gets or sets a value indicating whether should be intercepted and sent /// as a message to the workflow for handling, instead of being raised as a request. /// public bool InterceptUserInputRequests { get; set; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs index a38f49681a..9cc72d7310 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs @@ -19,7 +19,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor private AgentSession? _session; private bool? _currentTurnEmitEvents; - private AIContentExternalHandler? _userInputHandler; + private AIContentExternalHandler? _userInputHandler; private AIContentExternalHandler? _functionCallHandler; private static readonly ChatProtocolExecutorOptions s_defaultChatProtocolOptions = new() @@ -38,7 +38,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor private ProtocolBuilder ConfigureUserInputHandling(ProtocolBuilder protocolBuilder) { - this._userInputHandler = new AIContentExternalHandler( + this._userInputHandler = new AIContentExternalHandler( ref protocolBuilder, portId: $"{this.Id}_UserInput", intercepted: this._options.InterceptUserInputRequests, @@ -59,13 +59,13 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor } private ValueTask HandleUserInputResponseAsync( - UserInputResponseContent response, + ToolApprovalResponseContent response, IWorkflowContext context, CancellationToken cancellationToken) { - if (!this._userInputHandler!.MarkRequestAsHandled(response.Id)) + if (!this._userInputHandler!.MarkRequestAsHandled(response.RequestId)) { - throw new InvalidOperationException($"No pending UserInputRequest found with id '{response.Id}'."); + throw new InvalidOperationException($"No pending ToolApprovalRequest found with id '{response.RequestId}'."); } List implicitTurnMessages = [new ChatMessage(ChatRole.User, [response])]; @@ -164,7 +164,7 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default) { #pragma warning disable MEAI001 - Dictionary userInputRequests = new(); + Dictionary userInputRequests = new(); Dictionary functionCalls = new(); AgentResponse response; @@ -218,15 +218,15 @@ internal sealed class AIAgentHostExecutor : ChatProtocolExecutor { foreach (AIContent content in contents) { - if (content is UserInputRequestContent userInputRequest) + if (content is ToolApprovalRequestContent userInputRequest) { // It is an error to simultaneously have multiple outstanding user input requests with the same ID. - userInputRequests.Add(userInputRequest.Id, userInputRequest); + userInputRequests.Add(userInputRequest.RequestId, userInputRequest); } - else if (content is UserInputResponseContent userInputResponse) + else if (content is ToolApprovalResponseContent userInputResponse) { // If the set of messages somehow already has a corresponding user input response, remove it. - _ = userInputRequests.Remove(userInputResponse.Id); + _ = userInputRequests.Remove(userInputResponse.RequestId); } else if (content is FunctionCallContent functionCall) { diff --git a/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs index e179058e69..c2a2770226 100644 --- a/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs +++ b/dotnet/src/Shared/Foundry/Agents/AgentFactory.cs @@ -4,8 +4,9 @@ using System; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; namespace Shared.Foundry; diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs index 0f4f0c9217..8135c25570 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs @@ -306,8 +306,7 @@ internal sealed class WorkflowRunner requestItem switch { FunctionCallContent functionCall when !functionCall.InformationalOnly => await InvokeFunctionAsync(functionCall).ConfigureAwait(false), - FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest), - McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest), + ToolApprovalRequestContent approvalRequest => ApproveToolCall(approvalRequest), _ => HandleUnknown(requestItem), }; @@ -325,16 +324,16 @@ internal sealed class WorkflowRunner return null; } - ChatMessage ApproveFunction(FunctionApprovalRequestContent functionApprovalRequest) + ChatMessage ApproveToolCall(ToolApprovalRequestContent approvalRequest) { - Notify($"INPUT - Approving Function: {functionApprovalRequest.FunctionCall.Name}"); - return new ChatMessage(ChatRole.User, [functionApprovalRequest.CreateResponse(approved: true)]); - } - - ChatMessage ApproveMCP(McpServerToolApprovalRequestContent mcpApprovalRequest) - { - Notify($"INPUT - Approving MCP: {mcpApprovalRequest.ToolCall.ToolName}"); - return new ChatMessage(ChatRole.User, [mcpApprovalRequest.CreateResponse(approved: true)]); + string toolName = approvalRequest.ToolCall switch + { + McpServerToolCallContent mcp => mcp.Name, + FunctionCallContent f => f.Name, + _ => approvalRequest.ToolCall!.CallId + }; + Notify($"INPUT - Approving: {toolName}"); + return new ChatMessage(ChatRole.User, [approvalRequest.CreateResponse(approved: true)]); } async Task InvokeFunctionAsync(FunctionCallContent functionCall) diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs index a6691a41bd..e1cef1f1aa 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientCreateTests.cs @@ -5,7 +5,7 @@ using System.IO; using System.Threading.Tasks; using AgentConformance.IntegrationTests.Support; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Files; @@ -56,8 +56,8 @@ public class AIProjectClientCreateTests var agentRecord = await this._client.Agents.GetAgentAsync(agent.Name); Assert.NotNull(agentRecord); Assert.Equal(AgentName, agentRecord.Value.Name); - var definition = Assert.IsType(agentRecord.Value.Versions.Latest.Definition); - Assert.Equal(AgentDescription, agentRecord.Value.Versions.Latest.Description); + var definition = Assert.IsType(agentRecord.Value.GetLatestVersion().Definition); + Assert.Equal(AgentDescription, agentRecord.Value.GetLatestVersion().Description); Assert.Equal(AgentInstructions, definition.Instructions); } finally diff --git a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs index 6356bb6e01..42892b99b3 100644 --- a/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs +++ b/dotnet/tests/AzureAI.IntegrationTests/AIProjectClientFixture.cs @@ -6,8 +6,8 @@ using System.Linq; using System.Threading.Tasks; using AgentConformance.IntegrationTests; using AgentConformance.IntegrationTests.Support; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI.Responses; diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs index 84e3d5d9f9..b4a581d81e 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunStreamingTests.cs @@ -4,6 +4,10 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsChatClientAgentRunStreamingTests() : ChatClientAgentRunStreamingTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs index b2f75c536e..8529dac6b4 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsChatClientAgentRunTests.cs @@ -4,6 +4,10 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsChatClientAgentRunTests() : ChatClientAgentRunTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs index 20f6a4cda4..b5098ddd74 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs @@ -12,6 +12,10 @@ using Shared.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsPersistentCreateTests { private const string SkipCodeInterpreterReason = "Azure AI Code Interpreter intermittently fails to execute uploaded files in CI"; diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs index e18812aff4..87f1cfbb70 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunStreamingTests.cs @@ -4,6 +4,10 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsPersistentRunStreamingTests() : RunStreamingTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs index 3e6032401d..fd4b92e4b9 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentRunTests.cs @@ -4,6 +4,10 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsPersistentRunTests() : RunTests(() => new()) { } diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs index 0fa20f18ac..34663c29e4 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentStructuredOutputRunTests.cs @@ -5,6 +5,10 @@ using AgentConformance.IntegrationTests; namespace AzureAIAgentsPersistent.IntegrationTests; +// Disabled: Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent +// which was removed in ME.AI 10.4.0. Re-enable once Persistent targets ME.AI 10.4.0+ (expected in 1.2.0-beta.10). +// Tracking: https://github.com/microsoft/agent-framework/issues/4769 +[Trait("Category", "IntegrationDisabled")] public class AzureAIAgentsPersistentStructuredOutputRunTests() : StructuredOutputRunTests(() => new()) { private const string SkipReason = "Fails intermittently on the build agent/CI"; diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs index 65726bb2aa..261faaded8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/AzureAIProjectChatClientExtensionsTests.cs @@ -12,8 +12,9 @@ using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; +using Azure.AI.Extensions.OpenAI; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; using Moq; using OpenAI.Responses; @@ -369,7 +370,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests public async Task GetAIAgentAsync_ByName_WithNonExistentAgent_ThrowsInvalidOperationExceptionAsync() { // Arrange - var mockAgentOperations = new Mock(); + var mockAgentOperations = new Mock(); mockAgentOperations .Setup(c => c.GetAgentAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(ClientResult.FromOptionalValue((AgentRecord)null!, new MockPipelineResponse(200, BinaryData.FromString("null")))); @@ -889,12 +890,12 @@ public sealed class AzureAIProjectChatClientExtensionsTests // Add tools to the definition definition.Tools.Add(ResponseTool.CreateFunctionTool("create_tool", BinaryData.FromString("{}"), strictModeEnabled: false)); - definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolParameters([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); - definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolParameters(new BrowserAutomationToolConnectionParameters("id")))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBingCustomSearchTool(new BingCustomSearchToolOptions([new BingCustomSearchConfiguration("connection-id", "instance-name")]))); + definition.Tools.Add((ResponseTool)AgentTool.CreateBrowserAutomationTool(new BrowserAutomationToolOptions(new BrowserAutomationToolConnectionParameters("id")))); definition.Tools.Add(AgentTool.CreateA2ATool(new Uri("https://test-uri.microsoft.com"))); definition.Tools.Add((ResponseTool)AgentTool.CreateBingGroundingTool(new BingGroundingSearchToolOptions([new BingGroundingSearchConfiguration("connection-id")]))); definition.Tools.Add((ResponseTool)AgentTool.CreateMicrosoftFabricTool(fabricToolOptions)); - definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenAPIFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); + definition.Tools.Add((ResponseTool)AgentTool.CreateOpenApiTool(new OpenApiFunctionDefinition("name", BinaryData.FromString(OpenAPISpec), new OpenAPIAnonymousAuthenticationDetails()))); definition.Tools.Add((ResponseTool)AgentTool.CreateSharepointTool(sharepointOptions)); definition.Tools.Add((ResponseTool)AgentTool.CreateStructuredOutputsTool(structuredOutputs)); definition.Tools.Add((ResponseTool)AgentTool.CreateAzureAISearchTool(new AzureAISearchToolOptions([new AzureAISearchToolIndex() { IndexName = "name" }]))); @@ -3020,7 +3021,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests { // Handle backward compatibility with bool parameter var effectiveVersionMode = useEmptyVersion ? VersionMode.Empty : versionMode; - this.Agents = new FakeAIProjectAgentsOperations(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); + this.Agents = new FakeAgentsClient(agentName, instructions, description, agentDefinitionResponse, effectiveVersionMode); } public override ClientConnection GetConnection(string connectionId) @@ -3028,9 +3029,9 @@ public sealed class AzureAIProjectChatClientExtensionsTests return new ClientConnection("fake-connection-id", "http://localhost", ClientPipeline.Create(), CredentialKind.None); } - public override AIProjectAgentsOperations Agents { get; } + public override AgentsClient Agents { get; } - private sealed class FakeAIProjectAgentsOperations : AIProjectAgentsOperations + private sealed class FakeAgentsClient : AgentsClient { private readonly string? _agentName; private readonly string? _instructions; @@ -3038,7 +3039,7 @@ public sealed class AzureAIProjectChatClientExtensionsTests private readonly AgentDefinition? _agentDefinition; private readonly VersionMode _versionMode; - public FakeAIProjectAgentsOperations(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) + public FakeAgentsClient(string? agentName = null, string? instructions = null, string? description = null, AgentDefinition? agentDefinitionResponse = null, VersionMode versionMode = VersionMode.Normal) { this._agentName = agentName; this._instructions = instructions; diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs index 8471ddbcf1..0a33c03ccd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.UnitTests/TestDataUtil.cs @@ -2,7 +2,7 @@ using System.ClientModel.Primitives; using System.IO; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; namespace Microsoft.Agents.AI.AzureAI.UnitTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs index 296217f931..683c4c0cb4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/FunctionApprovalTests.cs @@ -20,7 +20,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase // Streaming request JSON for OpenAI Responses API private const string StreamingRequestJson = @"{""model"":""gpt-4o-mini"",""input"":""test"",""stream"":true}"; - #region FunctionApprovalRequestContent Tests + #region ToolApprovalRequestContent Tests [Fact] public async Task FunctionApprovalRequest_GeneratesCorrectEvent_SuccessAsync() @@ -34,7 +34,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments); - FunctionApprovalRequestContent approvalRequest = new(RequestId, functionCall); + ToolApprovalRequestContent approvalRequest = new(RequestId, functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -81,7 +81,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments); - FunctionApprovalRequestContent approvalRequest = new(RequestId, functionCall); + ToolApprovalRequestContent approvalRequest = new(RequestId, functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -114,7 +114,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new("call-1", "test_function", new Dictionary()); - FunctionApprovalRequestContent approvalRequest = new("req-1", functionCall); + ToolApprovalRequestContent approvalRequest = new("req-1", functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -150,7 +150,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new("call-1", "test", new Dictionary()); - FunctionApprovalRequestContent approvalRequest = new("req-1", functionCall); + ToolApprovalRequestContent approvalRequest = new("req-1", functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -173,7 +173,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #endregion - #region FunctionApprovalResponseContent Tests + #region ToolApprovalResponseContent Tests [Fact] public async Task FunctionApprovalResponse_Approved_GeneratesCorrectEvent_SuccessAsync() @@ -187,7 +187,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new(FunctionId, FunctionName, arguments); - FunctionApprovalResponseContent approvalResponse = new(RequestId, approved: true, functionCall); + ToolApprovalResponseContent approvalResponse = new(RequestId, approved: true, functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -221,7 +221,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new(FunctionId, FunctionName, new Dictionary { ["path"] = "/important.txt" }); - FunctionApprovalResponseContent approvalResponse = new(RequestId, approved: false, functionCall); + ToolApprovalResponseContent approvalResponse = new(RequestId, approved: false, functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -249,7 +249,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new("call-1", "test_function", new Dictionary()); - FunctionApprovalResponseContent approvalResponse = new("req-1", approved: true, functionCall); + ToolApprovalResponseContent approvalResponse = new("req-1", approved: true, functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -279,7 +279,7 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall = new("call-mixed-1", "test", new Dictionary()); - FunctionApprovalRequestContent approvalRequest = new("req-mixed-1", functionCall); + ToolApprovalRequestContent approvalRequest = new("req-mixed-1", functionCall); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => @@ -308,10 +308,10 @@ public sealed class FunctionApprovalTests : ConformanceTestBase #pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates FunctionCallContent functionCall1 = new("call-multi-1", "function1", new Dictionary()); - FunctionApprovalRequestContent approvalRequest1 = new("req-multi-1", functionCall1); + ToolApprovalRequestContent approvalRequest1 = new("req-multi-1", functionCall1); FunctionCallContent functionCall2 = new("call-multi-2", "function2", new Dictionary()); - FunctionApprovalRequestContent approvalRequest2 = new("req-multi-2", functionCall2); + ToolApprovalRequestContent approvalRequest2 = new("req-multi-2", functionCall2); #pragma warning restore MEAI001 HttpClient client = await this.CreateTestServerAsync(AgentName, "You are a test agent.", string.Empty, (msg) => diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs index 0b9441d633..4c3896ec1d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/OpenAIResponsesIntegrationTests.cs @@ -52,7 +52,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Count to 3"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Count to 3"); // Assert List updates = []; @@ -93,7 +93,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Hello"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Hello"); // Assert Assert.NotNull(response); @@ -120,7 +120,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List updates = []; @@ -166,8 +166,8 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient2 = this.CreateResponseClient(Agent2Name); // Act - ResponseResult response1 = await responseClient1.CreateResponseAsync("Hello"); - ResponseResult response2 = await responseClient2.CreateResponseAsync("Hello"); + ResponseResult response1 = await responseClient1.CreateResponseAsync("test-model", "Hello"); + ResponseResult response2 = await responseClient2.CreateResponseAsync("test-model", "Hello"); // Assert string content1 = response1.GetOutputText(); @@ -193,10 +193,10 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - Non-streaming - ResponseResult nonStreamingResponse = await responseClient.CreateResponseAsync("Test"); + ResponseResult nonStreamingResponse = await responseClient.CreateResponseAsync("test-model", "Test"); // Act - Streaming - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); StringBuilder streamingContent = new(); await foreach (StreamingResponseUpdate update in streamingResult) { @@ -227,7 +227,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Test"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Test"); // Assert Assert.Equal(ResponseStatus.Completed, response.Status); @@ -250,7 +250,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List updates = []; @@ -289,7 +289,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List updates = []; @@ -319,7 +319,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Test"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Test"); // Assert Assert.NotNull(response.Id); @@ -343,7 +343,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Generate long text"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Generate long text"); // Assert StringBuilder contentBuilder = new(); @@ -374,7 +374,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List outputIndices = []; @@ -410,7 +410,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert StringBuilder contentBuilder = new(); @@ -440,7 +440,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert StringBuilder contentBuilder = new(); @@ -470,7 +470,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Test"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Test"); // Assert string content = response.GetOutputText(); @@ -492,7 +492,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List itemIds = []; @@ -530,7 +530,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable // Act & Assert - Make 5 sequential requests for (int i = 0; i < 5; i++) { - ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", $"Request {i}"); Assert.NotNull(response); Assert.Equal(ResponseStatus.Completed, response.Status); Assert.Equal(ExpectedResponse, response.GetOutputText()); @@ -554,7 +554,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable // Act & Assert - Make 3 sequential streaming requests for (int i = 0; i < 3; i++) { - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync($"Request {i}"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", $"Request {i}"); StringBuilder contentBuilder = new(); await foreach (StreamingResponseUpdate update in streamingResult) @@ -587,7 +587,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable List responseIds = []; for (int i = 0; i < 10; i++) { - ResponseResult response = await responseClient.CreateResponseAsync($"Request {i}"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", $"Request {i}"); responseIds.Add(response.Id); } @@ -611,7 +611,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List sequenceNumbers = []; @@ -644,7 +644,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Test"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Test"); // Assert Assert.NotNull(response.Model); @@ -666,7 +666,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert StringBuilder contentBuilder = new(); @@ -696,7 +696,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Hi"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Hi"); // Assert Assert.NotNull(response); @@ -719,7 +719,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List contentIndices = []; @@ -751,7 +751,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Test"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Test"); // Assert string content = response.GetOutputText(); @@ -774,7 +774,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert StringBuilder contentBuilder = new(); @@ -810,7 +810,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Show me an image"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Show me an image"); // Assert Assert.NotNull(response); @@ -837,7 +837,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Show me an image"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Show me an image"); // Assert List updates = []; @@ -871,7 +871,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Generate audio"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Generate audio"); // Assert Assert.NotNull(response); @@ -899,7 +899,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Generate audio"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Generate audio"); // Assert List updates = []; @@ -933,7 +933,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("What's the weather?"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "What's the weather?"); // Assert Assert.NotNull(response); @@ -960,7 +960,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Calculate 2+2"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Calculate 2+2"); // Assert List updates = []; @@ -991,7 +991,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - ResponseResult response = await responseClient.CreateResponseAsync("Show me various content"); + ResponseResult response = await responseClient.CreateResponseAsync("test-model", "Show me various content"); // Assert Assert.NotNull(response); @@ -1017,7 +1017,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Show me various content"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Show me various content"); // Assert List updates = []; @@ -1050,7 +1050,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List updates = []; @@ -1078,7 +1078,7 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable ResponsesClient responseClient = this.CreateResponseClient(AgentName); // Act - AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("Test"); + AsyncCollectionResult streamingResult = responseClient.CreateResponseStreamingAsync("test-model", "Test"); // Assert List updates = []; @@ -1273,7 +1273,6 @@ public sealed class OpenAIResponsesIntegrationTests : IAsyncDisposable private ResponsesClient CreateResponseClient(string agentName) { return new ResponsesClient( - model: "test-model", credential: new ApiKeyCredential("test-api-key"), options: new OpenAIClientOptions { diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs index 4cda58875e..48bdca287e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/OpenTelemetryAgentTests.cs @@ -603,7 +603,47 @@ public class OpenTelemetryAgentTests Assert.False(tags.ContainsKey("gen_ai.input.messages")); Assert.False(tags.ContainsKey("gen_ai.output.messages")); Assert.False(tags.ContainsKey("gen_ai.system_instructions")); - Assert.False(tags.ContainsKey("gen_ai.tool.definitions")); + + // gen_ai.tool.definitions is always emitted regardless of EnableSensitiveData (ME.AI 10.4.0+) + Assert.Equal(ReplaceWhitespace(""" + [ + { + "type": "function", + "name": "GetPersonAge", + "description": "Gets the age of a person by name.", + "parameters": { + "type": "object", + "properties": { + "personName": { + "type": "string" + } + }, + "required": [ + "personName" + ] + } + }, + { + "type": "web_search" + }, + { + "type": "function", + "name": "GetCurrentWeather", + "description": "Gets the current weather for a location.", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string" + } + }, + "required": [ + "location" + ] + } + } + ] + """), ReplaceWhitespace(tags["gen_ai.tool.definitions"])); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs index a4198d3a4c..58e6b96d10 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/AgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs index 98243dc4d3..05ad68fa3d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/FunctionToolAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using OpenAI.Responses; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs index 693d99b638..b25e921abe 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MarketingAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs index 91d63404bd..c65cc3ce57 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/MathChatAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs index 1b79e4e25e..6e3912a276 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/PoemAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs index dcb09a4798..e1278e6fb7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/TestAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs index 0d95342264..027a67e254 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Agents/VisionAgentProvider.cs @@ -3,7 +3,7 @@ using System; using System.Collections.Generic; using Azure.AI.Projects; -using Azure.AI.Projects.OpenAI; +using Azure.AI.Projects.Agents; using Microsoft.Extensions.Configuration; using Shared.Foundry; using Shared.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs index 0333bf4d1c..43e64bbb35 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Framework/WorkflowTest.cs @@ -94,7 +94,7 @@ public abstract class WorkflowTest(ITestOutputHelper output) : IntegrationTest(o while (current is not null) { - if (Directory.Exists(Path.Combine(current.FullName, ".git"))) + if (Directory.Exists(Path.Combine(current.FullName, "workflow-samples"))) { return current.FullName; } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs index 9d5efa6b6d..bd5b250eb8 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/InvokeToolWorkflowTest.cs @@ -123,9 +123,9 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati foreach (ChatMessage message in toolRequest.AgentResponse.Messages) { // Handle approval requests if present - foreach (FunctionApprovalRequestContent approvalRequest in message.Contents.OfType()) + foreach (ToolApprovalRequestContent approvalRequest in message.Contents.OfType()) { - this.Output.WriteLine($"APPROVAL REQUEST: {approvalRequest.FunctionCall.Name}"); + this.Output.WriteLine($"APPROVAL REQUEST: {((FunctionCallContent)approvalRequest.ToolCall).Name}"); // Auto-approve for testing results.Add(approvalRequest.CreateResponse(approved: true)); } @@ -233,12 +233,12 @@ public sealed class InvokeToolWorkflowTest(ITestOutputHelper output) : Integrati foreach (ChatMessage message in toolRequest.AgentResponse.Messages) { // Handle MCP approval requests if present - foreach (McpServerToolApprovalRequestContent approvalRequest in message.Contents.OfType()) + foreach (ToolApprovalRequestContent approvalRequest in message.Contents.OfType()) { - this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.Id}"); + this.Output.WriteLine($"MCP APPROVAL REQUEST: {approvalRequest.RequestId}"); // Respond based on test configuration - McpServerToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest); + ToolApprovalResponseContent response = approvalRequest.CreateResponse(approved: approveRequest); results.Add(response); this.Output.WriteLine($"MCP APPROVAL RESPONSE: {(approveRequest ? "Approved" : "Rejected")}"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs index cebdc60cb9..d732c11099 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputRequestTest.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Extensions.AI; @@ -33,8 +35,8 @@ public sealed class ExternalInputRequestTest(ITestOutputHelper output) : EventTe new ChatMessage( ChatRole.Assistant, [ - new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")), - new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")), + new ToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")), + new ToolApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")), new FunctionCallContent("call3", "myfunc"), new TextContent("Heya"), ]))); @@ -46,11 +48,14 @@ public sealed class ExternalInputRequestTest(ITestOutputHelper output) : EventTe ChatMessage messageCopy = Assert.Single(source.AgentResponse.Messages); Assert.Equal(messageCopy.Contents.Count, copy.AgentResponse.Messages[0].Contents.Count); - McpServerToolApprovalRequestContent mcpRequest = AssertContent(messageCopy); - Assert.Equal("call1", mcpRequest.Id); + List approvalRequests = messageCopy.Contents.OfType().ToList(); + Assert.Equal(2, approvalRequests.Count); - FunctionApprovalRequestContent functionRequest = AssertContent(messageCopy); - Assert.Equal("call2", functionRequest.Id); + ToolApprovalRequestContent mcpRequest = approvalRequests[0]; + Assert.Equal("call1", mcpRequest.RequestId); + + ToolApprovalRequestContent functionRequest = approvalRequests[1]; + Assert.Equal("call2", functionRequest.RequestId); FunctionCallContent functionCall = AssertContent(messageCopy); Assert.Equal("call3", functionCall.CallId); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs index 384664a68c..853851375e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Events/ExternalInputResponseTest.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; +using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Events; using Microsoft.Extensions.AI; @@ -32,8 +34,8 @@ public sealed class ExternalInputResponseTest(ITestOutputHelper output) : EventT new(new ChatMessage( ChatRole.Assistant, [ - new McpServerToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true), - new FunctionApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true), + new ToolApprovalRequestContent("call1", new McpServerToolCallContent("call1", "testmcp", "server-name")).CreateResponse(approved: true), + new ToolApprovalRequestContent("call2", new FunctionCallContent("call2", "result1")).CreateResponse(approved: true), new FunctionResultContent("call3", 33), new TextContent("Heya"), ])); @@ -45,11 +47,14 @@ public sealed class ExternalInputResponseTest(ITestOutputHelper output) : EventT ChatMessage responseMessage = Assert.Single(source.Messages); Assert.Equal(responseMessage.Contents.Count, copy.Messages[0].Contents.Count); - McpServerToolApprovalResponseContent mcpApproval = AssertContent(responseMessage); - Assert.Equal("call1", mcpApproval.Id); + List approvalResponses = responseMessage.Contents.OfType().ToList(); + Assert.Equal(2, approvalResponses.Count); - FunctionApprovalResponseContent functionApproval = AssertContent(responseMessage); - Assert.Equal("call2", functionApproval.Id); + ToolApprovalResponseContent mcpApproval = approvalResponses[0]; + Assert.Equal("call1", mcpApproval.RequestId); + + ToolApprovalResponseContent functionApproval = approvalResponses[1]; + Assert.Equal("call2", functionApproval.RequestId); FunctionResultContent functionResult = AssertContent(responseMessage); Assert.Equal("call3", functionResult.CallId); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index 45b0b3c7b7..a1337b3e2d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -473,8 +473,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval request then response McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); - McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -501,8 +501,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval request then response (rejected) McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); - McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: false); + ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: false); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -552,8 +552,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval with different ID McpServerToolCallContent toolCall = new("different_id", TestToolName, TestServerUrl); - McpServerToolApprovalRequestContent approvalRequest = new("different_id", toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ToolApprovalRequestContent approvalRequest = new("different_id", toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -582,8 +582,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval request then response McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); - McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -613,8 +613,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval request then response McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerLabel); - McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -643,8 +643,8 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl // Create approval request then response McpServerToolCallContent toolCall = new(action.Id, TestToolName, TestServerUrl); - McpServerToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); - McpServerToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); + ToolApprovalRequestContent approvalRequest = new(action.Id, toolCall); + ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved: true); ExternalInputResponse response = new(new ChatMessage(ChatRole.User, [approvalResponse])); // Act @@ -799,31 +799,31 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl if (returnNullOutput) { - result.Output = null; + result.Outputs = null; } else if (returnEmptyOutput) { - result.Output = []; + result.Outputs = []; } else if (returnJsonObject) { - result.Output = [new TextContent("{\"key\": \"value\", \"number\": 42}")]; + result.Outputs = [new TextContent("{\"key\": \"value\", \"number\": 42}")]; } else if (returnJsonArray) { - result.Output = [new TextContent("[1, 2, 3, \"four\"]")]; + result.Outputs = [new TextContent("[1, 2, 3, \"four\"]")]; } else if (returnInvalidJson) { - result.Output = [new TextContent("this is not valid json {")]; + result.Outputs = [new TextContent("this is not valid json {")]; } else if (returnDataContent) { - result.Output = [new DataContent("data:image/png;base64,iVBORw0KGgo=", "image/png")]; + result.Outputs = [new DataContent("data:image/png;base64,iVBORw0KGgo=", "image/png")]; } else if (returnMultipleContent) { - result.Output = + result.Outputs = [ new TextContent("First text"), new TextContent("{\"nested\": true}"), @@ -832,7 +832,7 @@ public sealed class InvokeMcpToolExecutorTest(ITestOutputHelper output) : Workfl } else { - result.Output = [new TextContent("Mock MCP tool result")]; + result.Outputs = [new TextContent("Mock MCP tool result")]; } return Task.FromResult(result); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs index 2ea117856f..063bd77cda 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs @@ -229,7 +229,7 @@ public class AIAgentHostExecutorTests responses = ExtractAndValidateRequestContents(); break; case TestAgentRequestType.UserInputRequest: - responses = ExtractAndValidateRequestContents(); + responses = ExtractAndValidateRequestContents(); break; default: throw new NotSupportedException(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs index 4faeff29a1..32b082ad6d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TestRequestAgent.cs @@ -33,7 +33,7 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp => new(requestType switch { TestAgentRequestType.FunctionCall => new TestRequestAgentSession(), - TestAgentRequestType.UserInputRequest => new TestRequestAgentSession(), + TestAgentRequestType.UserInputRequest => new TestRequestAgentSession(), _ => throw new NotSupportedException(), }); @@ -41,7 +41,7 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp => new(requestType switch { TestAgentRequestType.FunctionCall => new TestRequestAgentSession(), - TestAgentRequestType.UserInputRequest => new TestRequestAgentSession(), + TestAgentRequestType.UserInputRequest => new TestRequestAgentSession(), _ => throw new NotSupportedException(), }); @@ -179,58 +179,43 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp } } - private sealed class FunctionApprovalStrategy : IRequestResponseStrategy + private sealed class FunctionApprovalStrategy : IRequestResponseStrategy { - public UserInputResponseContent CreatePairedResponse(UserInputRequestContent request) + public ToolApprovalResponseContent CreatePairedResponse(ToolApprovalRequestContent request) { - if (request is not FunctionApprovalRequestContent approvalRequest) - { - throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}"); - } - - return new FunctionApprovalResponseContent(approvalRequest.Id, true, approvalRequest.FunctionCall); + return new ToolApprovalResponseContent(request.RequestId, true, request.ToolCall); } - public IEnumerable<(string, UserInputRequestContent)> CreateRequests(int count) + public IEnumerable<(string, ToolApprovalRequestContent)> CreateRequests(int count) { for (int i = 0; i < count; i++) { string id = Guid.NewGuid().ToString("N"); - UserInputRequestContent request = new FunctionApprovalRequestContent(id, new(id, "TestFunction")); + ToolApprovalRequestContent request = new(id, new FunctionCallContent(id, "TestFunction")); yield return (id, request); } } - public void ProcessResponse(UserInputResponseContent response, TestRequestAgentSession session) + public void ProcessResponse(ToolApprovalResponseContent response, TestRequestAgentSession session) { - if (session.UnservicedRequests.TryGetValue(response.Id, out UserInputRequestContent? request)) + if (session.UnservicedRequests.TryGetValue(response.RequestId, out ToolApprovalRequestContent? request)) { - if (request is not FunctionApprovalRequestContent approvalRequest) - { - throw new InvalidOperationException($"Invalid request: Expecting {typeof(FunctionApprovalResponseContent)}, got {request.GetType()}"); - } - - if (response is not FunctionApprovalResponseContent approvalResponse) - { - throw new InvalidOperationException($"Invalid response: Expecting {typeof(FunctionApprovalResponseContent)}, got {response.GetType()}"); - } - - approvalResponse.Approved.Should().BeTrue(); - approvalResponse.FunctionCall.As().Should().Be(approvalRequest.FunctionCall); - session.ServicedRequests.Add(response.Id); - session.UnservicedRequests.Remove(response.Id); + response.Approved.Should().BeTrue(); + ((FunctionCallContent)response.ToolCall).Should().Be((FunctionCallContent)request.ToolCall); + session.ServicedRequests.Add(response.RequestId); + session.UnservicedRequests.Remove(response.RequestId); } - else if (session.ServicedRequests.Contains(response.Id)) + else if (session.ServicedRequests.Contains(response.RequestId)) { - throw new InvalidOperationException($"Seeing duplicate response with id {response.Id}"); + throw new InvalidOperationException($"Seeing duplicate response with id {response.RequestId}"); } - else if (session.PairedRequests.Contains(response.Id)) + else if (session.PairedRequests.Contains(response.RequestId)) { - throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.Id}"); + throw new InvalidOperationException($"Seeing explicit response to initially paired request with id {response.RequestId}"); } else { - throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.Id}"); + throw new InvalidOperationException($"Seeing response to nonexistent request with id {response.RequestId}"); } } } @@ -261,7 +246,7 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp return request switch { FunctionCallContent functionCall => functionCall.CallId, - UserInputRequestContent userInputRequest => userInputRequest.Id, + ToolApprovalRequestContent userInputRequest => userInputRequest.RequestId, _ => throw new NotSupportedException($"Unknown request type {typeof(TRequest)}"), }; } @@ -295,12 +280,12 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp return this.ValidateUnpairedRequests((IEnumerable)requests, new FunctionCallStrategy()); case TestAgentRequestType.UserInputRequest: - if (!typeof(UserInputRequestContent).IsAssignableFrom(typeof(TRequest))) + if (!typeof(ToolApprovalRequestContent).IsAssignableFrom(typeof(TRequest))) { - throw new ArgumentException($"Invalid request type: Expected {typeof(UserInputRequestContent)}, got {typeof(TRequest)}", nameof(requests)); + throw new ArgumentException($"Invalid request type: Expected {typeof(ToolApprovalRequestContent)}, got {typeof(TRequest)}", nameof(requests)); } - return this.ValidateUnpairedRequests((IEnumerable)requests, new FunctionApprovalStrategy()); + return this.ValidateUnpairedRequests((IEnumerable)requests, new FunctionApprovalStrategy()); default: throw new NotSupportedException($"Unknown AgentRequestType {requestType}"); } @@ -315,7 +300,7 @@ internal sealed class TestRequestAgent(TestAgentRequestType requestType, int unp responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent)).ToList(); break; case TestAgentRequestType.UserInputRequest: - responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent)).ToList(); + responses = this.ValidateUnpairedRequests(requests.Select(AssertAndExtractRequestContent)).ToList(); break; default: throw new NotSupportedException($"Unknown AgentRequestType {requestType}"); diff --git a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs index 74c7ef9041..20f79d0ad1 100644 --- a/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs +++ b/dotnet/tests/OpenAIResponse.IntegrationTests/OpenAIResponseFixture.cs @@ -17,6 +17,7 @@ namespace ResponseResult.IntegrationTests; public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture { private ResponsesClient _openAIResponseClient = null!; + private string _modelName = null!; private ChatClientAgent _agent = null!; public AIAgent Agent => this._agent; @@ -74,7 +75,7 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture string instructions = "You are a helpful assistant.", IList? aiTools = null) => new( - this._openAIResponseClient.AsIChatClient(), + this._openAIResponseClient.AsIChatClient(this._modelName), options: new() { Name = name, @@ -96,8 +97,9 @@ public class OpenAIResponseFixture(bool store) : IChatClientAgentFixture public async ValueTask InitializeAsync() { + this._modelName = TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName); this._openAIResponseClient = new OpenAIClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIApiKey)) - .GetResponsesClient(TestConfiguration.GetRequiredValue(TestSettings.OpenAIChatModelName)); + .GetResponsesClient(); this._agent = await this.CreateChatClientAgentAsync(); } From 51828abed41bc92d38eb144fe302b4b5f44e78fc Mon Sep 17 00:00:00 2001 From: Tao Chen Date: Fri, 20 Mar 2026 11:27:02 -0700 Subject: [PATCH 14/19] [BREAKING] Python: Add context mode to AgentExecutor (#4668) * Add context mode to AgentExecutor * Fix unit tests * Address comments * Address comments * REvise context mode and add tests * Add chain config to sequential builder * Add sample * Fix pipeline * Address comments * Address comments --- .../azurefunctions/tests/test_func_utils.py | 1 + .../azurefunctions/tests/test_workflow.py | 5 + .../_workflows/_agent_executor.py | 85 +++--- .../tests/workflow/test_agent_executor.py | 264 +++++++++++++++--- .../core/tests/workflow/test_runner.py | 2 +- .../_concurrent.py | 11 +- .../_orchestration_request_info.py | 14 +- .../_sequential.py | 15 +- .../tests/test_orchestration_request_info.py | 3 + .../orchestrations/tests/test_sequential.py | 157 ++++++++++- .../sequential_chain_only_agent_responses.py | 81 ++++++ 11 files changed, 549 insertions(+), 89 deletions(-) create mode 100644 python/samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py diff --git a/python/packages/azurefunctions/tests/test_func_utils.py b/python/packages/azurefunctions/tests/test_func_utils.py index 63f0af0182..9155bad33e 100644 --- a/python/packages/azurefunctions/tests/test_func_utils.py +++ b/python/packages/azurefunctions/tests/test_func_utils.py @@ -232,6 +232,7 @@ class TestSerializationRoundtrip: original = AgentExecutorResponse( executor_id="test_exec", agent_response=AgentResponse(messages=[Message(role="assistant", text="Reply")]), + full_conversation=[Message(role="assistant", text="Reply")], ) encoded = serialize_value(original) decoded = deserialize_value(encoded) diff --git a/python/packages/azurefunctions/tests/test_workflow.py b/python/packages/azurefunctions/tests/test_workflow.py index 4c26c980b2..baba1c2602 100644 --- a/python/packages/azurefunctions/tests/test_workflow.py +++ b/python/packages/azurefunctions/tests/test_workflow.py @@ -212,6 +212,7 @@ class TestExtractMessageContent: response = AgentExecutorResponse( executor_id="exec", agent_response=AgentResponse(messages=[Message(role="assistant", text="Response text")]), + full_conversation=[Message(role="assistant", text="Response text")], ) result = _extract_message_content(response) @@ -228,6 +229,10 @@ class TestExtractMessageContent: Message(role="assistant", text="Last message"), ] ), + full_conversation=[ + Message(role="user", text="First"), + Message(role="assistant", text="Last message"), + ], ) result = _extract_message_content(response) diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index ac2ebcf56f..462c3f8c64 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -4,7 +4,7 @@ import logging import sys from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass -from typing import Any, cast +from typing import Any, Literal, cast from typing_extensions import Never @@ -57,7 +57,7 @@ class AgentExecutorResponse: executor_id: str agent_response: AgentResponse - full_conversation: list[Message] | None = None + full_conversation: list[Message] class AgentExecutor(Executor): @@ -83,6 +83,8 @@ class AgentExecutor(Executor): *, session: AgentSession | None = None, id: str | None = None, + context_mode: Literal["full", "last_agent", "custom"] | None = None, + context_filter: Callable[[list[Message]], list[Message]] | None = None, ): """Initialize the executor with a unique identifier. @@ -90,6 +92,16 @@ class AgentExecutor(Executor): agent: The agent to be wrapped by this executor. session: The session to use for running the agent. If None, a new session will be created. id: A unique identifier for the executor. If None, the agent's name will be used if available. + context_mode: Configuration for how the executor should manage conversation context upon + receiving an AgentExecutorResponse as input. Options: + - "full": append the full conversation (all prior messages + latest agent response) to the + cache for the agent run. This is the default mode. + - "last_agent": provide only the messages from the latest agent response as context for + the agent run. + - "custom": use the provided context_filter function to determine which messages to include + as context for the agent run. + context_filter: An optional function for filtering conversation context when context_mode is set + to "custom". """ # Prefer provided id; else use agent.name if present; else generate deterministic prefix exec_id = id or resolve_agent_id(agent) @@ -107,6 +119,14 @@ class AgentExecutor(Executor): # This tracks the full conversation after each run self._full_conversation: list[Message] = [] + # Context mode validation + self._context_mode = context_mode or "full" + self._context_filter = context_filter + if self._context_mode not in {"full", "last_agent", "custom"}: + raise ValueError("context_mode must be one of 'full', 'last_agent', or 'custom'.") + if self._context_mode == "custom" and not self._context_filter: + raise ValueError("context_filter must be provided when context_mode is set to 'custom'.") + @property def agent(self) -> SupportsAgentRun: """Get the underlying agent wrapped by this executor.""" @@ -129,6 +149,7 @@ class AgentExecutor(Executor): run the agent and emit an AgentExecutorResponse downstream. """ self._cache.extend(request.messages) + if request.should_respond: await self._run_agent_and_emit(ctx) @@ -143,19 +164,27 @@ class AgentExecutor(Executor): Strategy: treat the prior response's messages as the conversation state and immediately run the agent to produce a new response. """ - # Replace cache with full conversation if available, else fall back to agent_response messages. - source_messages = ( - prior.full_conversation if prior.full_conversation is not None else prior.agent_response.messages - ) - self._cache = list(source_messages) + if self._context_mode == "full": + self._cache.extend(prior.full_conversation) + elif self._context_mode == "last_agent": + self._cache.extend(prior.agent_response.messages) + else: + if not self._context_filter: + # This should never happen due to validation in __init__, but mypy doesn't track that well + raise ValueError("context_filter function must be provided for 'custom' context_mode.") + self._cache.extend(self._context_filter(prior.full_conversation)) + await self._run_agent_and_emit(ctx) @handler async def from_str( self, text: str, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate] ) -> None: - """Accept a raw user prompt string and run the agent (one-shot).""" - self._cache = normalize_messages_input(text) + """Accept a raw user prompt string and run the agent. + + The new string input will be added to the cache which is used as the conversation context for the agent run. + """ + self._cache.extend(normalize_messages_input(text)) await self._run_agent_and_emit(ctx) @handler @@ -164,8 +193,11 @@ class AgentExecutor(Executor): message: Message, ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: - """Accept a single Message as input.""" - self._cache = normalize_messages_input(message) + """Accept a single Message as input. + + The new message will be added to the cache which is used as the conversation context for the agent run. + """ + self._cache.extend(normalize_messages_input(message)) await self._run_agent_and_emit(ctx) @handler @@ -174,8 +206,11 @@ class AgentExecutor(Executor): messages: list[str | Message], ctx: WorkflowContext[AgentExecutorResponse, AgentResponse | AgentResponseUpdate], ) -> None: - """Accept a list of chat inputs (strings or Message) as conversation context.""" - self._cache = normalize_messages_input(messages) + """Accept a list of chat inputs (strings or Message) as conversation context. + + The new messages will be added to the cache which is used as the conversation context for the agent run. + """ + self._cache.extend(normalize_messages_input(messages)) await self._run_agent_and_emit(ctx) @response_handler @@ -249,24 +284,10 @@ class AgentExecutor(Executor): state: Checkpoint data dict """ cache_payload = state.get("cache") - if cache_payload: - try: - self._cache = cache_payload - except Exception as exc: - logger.warning("Failed to restore cache: %s", exc) - self._cache = [] - else: - self._cache = [] + self._cache = cache_payload or [] full_conversation_payload = state.get("full_conversation") - if full_conversation_payload: - try: - self._full_conversation = full_conversation_payload - except Exception as exc: - logger.warning("Failed to restore full conversation: %s", exc) - self._full_conversation = [] - else: - self._full_conversation = [] + self._full_conversation = full_conversation_payload or [] session_payload = state.get("agent_session") if session_payload: @@ -279,12 +300,10 @@ class AgentExecutor(Executor): self._session = self._agent.create_session() pending_requests_payload = state.get("pending_agent_requests") - if pending_requests_payload: - self._pending_agent_requests = pending_requests_payload + self._pending_agent_requests = pending_requests_payload or {} pending_responses_payload = state.get("pending_responses_to_agent") - if pending_responses_payload: - self._pending_responses_to_agent = pending_responses_payload + self._pending_responses_to_agent = pending_responses_payload or [] def reset(self) -> None: """Reset the internal cache of the executor.""" diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index 059e683745..6298a8963d 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -16,12 +16,12 @@ from agent_framework import ( Content, Message, ResponseStream, + WorkflowBuilder, WorkflowEvent, WorkflowRunState, ) from agent_framework._workflows._agent_executor import AgentExecutorResponse from agent_framework._workflows._checkpoint import InMemoryCheckpointStorage -from agent_framework.orchestrations import SequentialBuilder if TYPE_CHECKING: from _pytest.logging import LogCaptureFixture @@ -139,7 +139,7 @@ async def test_agent_executor_streaming_finalizes_stream_and_runs_result_hooks() """AgentExecutor should call get_final_response() so stream result hooks execute.""" agent = _StreamingHookAgent(id="hook_agent", name="HookAgent") executor = AgentExecutor(agent, id="hook_exec") - workflow = SequentialBuilder(participants=[executor]).build() + workflow = WorkflowBuilder(start_executor=executor).build() output_events: list[Any] = [] async for event in workflow.run("run hook test", stream=True): @@ -154,8 +154,9 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: """Test that workflow checkpoint stores AgentExecutor's cache and session states and restores them correctly.""" storage = InMemoryCheckpointStorage() - # Create initial agent with a custom session - initial_agent = _CountingAgent(id="test_agent", name="TestAgent") + # Create two agents to form a two-step workflow + initial_agent_a = _CountingAgent(id="agent_a", name="AgentA") + initial_agent_b = _CountingAgent(id="agent_b", name="AgentB") initial_session = AgentSession() # Add some initial messages to the session state to verify session state persistence @@ -165,11 +166,12 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: ] initial_session.state["history"] = {"messages": initial_messages} - # Create AgentExecutor with the session - executor = AgentExecutor(initial_agent, session=initial_session) + # Create AgentExecutors — first executor gets the custom session + exec_a = AgentExecutor(initial_agent_a, id="exec_a", session=initial_session) + exec_b = AgentExecutor(initial_agent_b, id="exec_b") - # Build workflow with checkpointing enabled - wf = SequentialBuilder(participants=[executor], checkpoint_storage=storage).build() + # Build two-executor workflow with checkpointing enabled + wf = WorkflowBuilder(start_executor=exec_a, checkpoint_storage=storage).add_edge(exec_a, exec_b).build() # Run the workflow with a user message first_run_output: AgentExecutorResponse | None = None @@ -180,27 +182,25 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: break assert first_run_output is not None - assert initial_agent.call_count == 1 + assert initial_agent_a.call_count == 1 # Verify checkpoint was created checkpoints = await storage.list_checkpoints(workflow_name=wf.name) - assert len(checkpoints) >= 2, ( - "Expected at least 2 checkpoints. The first one is after the start executor, " - "and the second one is after the agent execution." + assert len(checkpoints) >= 2, "Expected at least 2 checkpoints: one after exec_a and one after exec_b." + + # Get the first checkpoint that contains exec_a's state (taken after exec_a completes, + # before exec_b runs) + checkpoints.sort(key=lambda cp: cp.timestamp) + restore_checkpoint = next( + cp for cp in checkpoints if "_executor_state" in cp.state and "exec_a" in cp.state["_executor_state"] ) - # Get the second checkpoint which should contain the state after processing - # the first message by the start executor in the sequential workflow - checkpoints.sort(key=lambda cp: cp.timestamp) - restore_checkpoint = checkpoints[1] - # Verify checkpoint contains executor state with both cache and session - assert "_executor_state" in restore_checkpoint.state executor_states = restore_checkpoint.state["_executor_state"] assert isinstance(executor_states, dict) - assert executor.id in executor_states + assert exec_a.id in executor_states - executor_state = executor_states[executor.id] # type: ignore[index] + executor_state = executor_states[exec_a.id] # type: ignore[index] assert "cache" in executor_state, "Checkpoint should store executor cache state" assert "agent_session" in executor_state, "Checkpoint should store executor session state" @@ -213,19 +213,26 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: assert "pending_agent_requests" in executor_state assert "pending_responses_to_agent" in executor_state - # Create a new agent and executor for restoration + # Create new agents and executors for restoration # This simulates starting from a fresh state and restoring from checkpoint - restored_agent = _CountingAgent(id="test_agent", name="TestAgent") + restored_agent_a = _CountingAgent(id="agent_a", name="AgentA") + restored_agent_b = _CountingAgent(id="agent_b", name="AgentB") restored_session = AgentSession() - restored_executor = AgentExecutor(restored_agent, session=restored_session) + restored_exec_a = AgentExecutor(restored_agent_a, id="exec_a", session=restored_session) + restored_exec_b = AgentExecutor(restored_agent_b, id="exec_b") - # Verify the restored agent starts with a fresh state - assert restored_agent.call_count == 0 + # Verify the restored agents start with a fresh state + assert restored_agent_a.call_count == 0 + assert restored_agent_b.call_count == 0 - # Build new workflow with the restored executor - wf_resume = SequentialBuilder(participants=[restored_executor], checkpoint_storage=storage).build() + # Build new workflow with the restored executors + wf_resume = ( + WorkflowBuilder(start_executor=restored_exec_a, checkpoint_storage=storage) + .add_edge(restored_exec_a, restored_exec_b) + .build() + ) - # Resume from checkpoint + # Resume from checkpoint — exec_a already ran, so exec_b should run and produce output resumed_output: AgentExecutorResponse | None = None async for ev in wf_resume.run(checkpoint_id=restore_checkpoint.checkpoint_id, stream=True): if ev.type == "output": @@ -239,7 +246,7 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: assert resumed_output is not None # Verify the restored executor's session state was restored - restored_session_obj = restored_executor._session # type: ignore[reportPrivateUsage] + restored_session_obj = restored_exec_a._session # type: ignore[reportPrivateUsage] assert restored_session_obj is not None assert restored_session_obj.session_id == initial_session.session_id @@ -306,7 +313,7 @@ async def test_agent_executor_run_with_session_kwarg_does_not_raise() -> None: """Passing session= via workflow.run() should not cause a duplicate-keyword TypeError (#4295).""" agent = _CountingAgent(id="session_kwarg_agent", name="SessionKwargAgent") executor = AgentExecutor(agent, id="session_kwarg_exec") - workflow = SequentialBuilder(participants=[executor]).build() + workflow = WorkflowBuilder(start_executor=executor).build() # This previously raised: TypeError: run() got multiple values for keyword argument 'session' result = await workflow.run("hello", session="user-supplied-value") @@ -318,7 +325,7 @@ async def test_agent_executor_run_streaming_with_stream_kwarg_does_not_raise() - """Passing stream= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" agent = _CountingAgent(id="stream_kwarg_agent", name="StreamKwargAgent") executor = AgentExecutor(agent, id="stream_kwarg_exec") - workflow = SequentialBuilder(participants=[executor]).build() + workflow = WorkflowBuilder(start_executor=executor).build() # stream=True at workflow level triggers streaming mode (returns async iterable) events: list[WorkflowEvent] = [] @@ -378,7 +385,7 @@ async def test_agent_executor_run_with_messages_kwarg_does_not_raise() -> None: """Passing messages= via workflow.run() kwargs should not cause a duplicate-keyword TypeError.""" agent = _CountingAgent(id="messages_kwarg_agent", name="MessagesKwargAgent") executor = AgentExecutor(agent, id="messages_kwarg_exec") - workflow = SequentialBuilder(participants=[executor]).build() + workflow = WorkflowBuilder(start_executor=executor).build() result = await workflow.run("hello", messages=["stale"]) assert result is not None @@ -426,7 +433,7 @@ async def test_agent_executor_workflow_with_non_copyable_raw_representation() -> exec_a = AgentExecutor(agent_a, id="exec_a") exec_b = AgentExecutor(agent_b, id="exec_b") - workflow = SequentialBuilder(participants=[exec_a, exec_b]).build() + workflow = WorkflowBuilder(start_executor=exec_a).add_edge(exec_a, exec_b).build() events = await workflow.run("hello") completed = [e for e in events if isinstance(e, WorkflowEvent) and e.type == "executor_completed"] @@ -440,3 +447,194 @@ async def test_agent_executor_workflow_with_non_copyable_raw_representation() -> assert len(agent_responses) > 0 assert agent_responses[0].text == "reply from AgentA" assert agent_responses[0].raw_representation is raw + + +# --------------------------------------------------------------------------- +# Context mode tests +# --------------------------------------------------------------------------- + + +class _MessageCapturingAgent(BaseAgent): + """Agent that records the messages it received and returns a configurable reply.""" + + def __init__(self, *, reply_text: str = "reply", **kwargs: Any): + super().__init__(**kwargs) + self.reply_text = reply_text + self.last_messages: list[Message] = [] + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + captured: list[Message] = [] + if messages: + for m in messages: # type: ignore[union-attr] + if isinstance(m, Message): + captured.append(m) + elif isinstance(m, str): + captured.append(Message("user", [m])) + self.last_messages = captured + + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=self.reply_text)]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", [self.reply_text])]) + + return _run() + + +def test_context_mode_custom_requires_context_filter() -> None: + """context_mode='custom' without context_filter must raise ValueError.""" + agent = _CountingAgent(id="a", name="A") + with pytest.raises(ValueError, match="context_filter must be provided"): + AgentExecutor(agent, context_mode="custom") + + +def test_context_mode_custom_with_filter_succeeds() -> None: + """context_mode='custom' with a context_filter should not raise.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, context_mode="custom", context_filter=lambda msgs: msgs[-1:]) + assert executor._context_mode == "custom" # pyright: ignore[reportPrivateUsage] + assert executor._context_filter is not None # pyright: ignore[reportPrivateUsage] + + +def test_context_mode_defaults_to_full() -> None: + """Default context_mode should be 'full'.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent) + assert executor._context_mode == "full" # pyright: ignore[reportPrivateUsage] + + +def test_context_mode_invalid_value_raises() -> None: + """Invalid context_mode value should raise ValueError.""" + agent = _CountingAgent(id="a", name="A") + with pytest.raises(ValueError, match="context_mode must be one of"): + AgentExecutor(agent, context_mode="invalid_mode") # type: ignore + + +async def test_from_response_context_mode_full_passes_full_conversation() -> None: + """context_mode='full' (default) should pass full_conversation to the second agent.""" + first = _MessageCapturingAgent(id="first", name="First", reply_text="first reply") + second = _MessageCapturingAgent(id="second", name="Second", reply_text="second reply") + + exec_a = AgentExecutor(first, id="exec_a") + exec_b = AgentExecutor(second, id="exec_b", context_mode="full") + + wf = WorkflowBuilder(start_executor=exec_a).add_edge(exec_a, exec_b).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # Second agent should see full conversation: [user("hello"), assistant("first reply")] + seen = second.last_messages + assert len(seen) == 2 + assert seen[0].role == "user" and "hello" in (seen[0].text or "") + assert seen[1].role == "assistant" and "first reply" in (seen[1].text or "") + + +async def test_from_response_context_mode_last_agent_passes_only_agent_messages() -> None: + """context_mode='last_agent' should pass only the previous agent's response messages.""" + first = _MessageCapturingAgent(id="first", name="First", reply_text="first reply") + second = _MessageCapturingAgent(id="second", name="Second", reply_text="second reply") + + exec_a = AgentExecutor(first, id="exec_a") + exec_b = AgentExecutor(second, id="exec_b", context_mode="last_agent") + + wf = WorkflowBuilder(start_executor=exec_a).add_edge(exec_a, exec_b).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # Second agent should see only the assistant message from first: [assistant("first reply")] + seen = second.last_messages + assert len(seen) == 1 + assert seen[0].role == "assistant" and "first reply" in (seen[0].text or "") + + +async def test_from_response_context_mode_custom_uses_filter() -> None: + """context_mode='custom' should invoke context_filter on full_conversation.""" + first = _MessageCapturingAgent(id="first", name="First", reply_text="first reply") + second = _MessageCapturingAgent(id="second", name="Second", reply_text="second reply") + + # Custom filter: keep only user messages + def only_user_messages(msgs: list[Message]) -> list[Message]: + return [m for m in msgs if m.role == "user"] + + exec_a = AgentExecutor(first, id="exec_a") + exec_b = AgentExecutor(second, id="exec_b", context_mode="custom", context_filter=only_user_messages) + + wf = WorkflowBuilder(start_executor=exec_a).add_edge(exec_a, exec_b).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # Second agent should see only user messages: [user("hello")] + seen = second.last_messages + assert len(seen) == 1 + assert seen[0].role == "user" and "hello" in (seen[0].text or "") + + +async def test_checkpoint_save_does_not_include_context_mode() -> None: + """on_checkpoint_save should not include context_mode in the saved state.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, context_mode="last_agent") + + state = await executor.on_checkpoint_save() + + assert "context_mode" not in state + assert "cache" in state + assert "agent_session" in state + + +async def test_checkpoint_restore_works_without_context_mode_in_state() -> None: + """on_checkpoint_restore should succeed when state does not contain context_mode.""" + agent = _CountingAgent(id="a", name="A") + executor = AgentExecutor(agent, context_mode="last_agent") + + # Simulate a checkpoint state without context_mode (as saved by the new code) + state: dict[str, Any] = { + "cache": [Message(role="user", text="cached msg")], + "full_conversation": [], + "agent_session": AgentSession().to_dict(), + "pending_agent_requests": {}, + "pending_responses_to_agent": [], + } + + await executor.on_checkpoint_restore(state) + + cache = executor._cache # pyright: ignore[reportPrivateUsage] + assert len(cache) == 1 + assert cache[0].text == "cached msg" + # context_mode should remain as configured in the constructor, not changed by restore + assert executor._context_mode == "last_agent" # pyright: ignore[reportPrivateUsage] diff --git a/python/packages/core/tests/workflow/test_runner.py b/python/packages/core/tests/workflow/test_runner.py index db6dccd9fa..a42e94f39d 100644 --- a/python/packages/core/tests/workflow/test_runner.py +++ b/python/packages/core/tests/workflow/test_runner.py @@ -341,7 +341,7 @@ async def test_runner_emits_runner_completion_for_agent_response_without_targets await ctx.send_message( WorkflowMessage( - data=AgentExecutorResponse("agent", AgentResponse()), + data=AgentExecutorResponse("agent", AgentResponse(), []), source_id="agent", ) ) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py index 062e87806c..d73b7e322b 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_concurrent.py @@ -100,24 +100,21 @@ class _AggregateAgentConversations(Executor): assistant_replies: list[Message] = [] for r in results: - resp_messages = list(getattr(r.agent_response, "messages", []) or []) - conv = r.full_conversation if r.full_conversation is not None else resp_messages + resp_messages = list(r.agent_response.messages) logger.debug( f"Aggregating executor {getattr(r, 'executor_id', '')}: " - f"{len(resp_messages)} response msgs, {len(conv)} conversation msgs" + f"{len(resp_messages)} response msgs, {len(r.full_conversation)} conversation msgs" ) # Capture a single user prompt (first encountered across any conversation) if prompt_message is None: - found_user = next((m for m in conv if _is_role(m, "user")), None) - if found_user is not None: - prompt_message = found_user + prompt_message = next((m for m in r.full_conversation if _is_role(m, "user")), None) # Pick the final assistant message from the response; fallback to conversation search final_assistant = next((m for m in reversed(resp_messages) if _is_role(m, "assistant")), None) if final_assistant is None: - final_assistant = next((m for m in reversed(conv) if _is_role(m, "assistant")), None) + final_assistant = next((m for m in reversed(r.full_conversation) if _is_role(m, "assistant")), None) if final_assistant is not None: assistant_replies.append(final_assistant) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py index 5e4a5d6a28..16950606dc 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_request_info.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft. All rights reserved. from dataclasses import dataclass +from typing import Literal from agent_framework._agents import SupportsAgentRun from agent_framework._types import Message @@ -117,18 +118,25 @@ class AgentApprovalExecutor(WorkflowExecutor): agent's output or send the final response to down stream executors in the orchestration. """ - def __init__(self, agent: SupportsAgentRun) -> None: + def __init__( + self, + agent: SupportsAgentRun, + context_mode: Literal["full", "last_agent", "custom"] | None = None, + ) -> None: """Initialize the AgentApprovalExecutor. Args: agent: The agent protocol to use for generating responses. + context_mode: The mode for providing context to the agent. """ - super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) + self._context_mode: Literal["full", "last_agent", "custom"] | None = context_mode self._description = agent.description + super().__init__(workflow=self._build_workflow(agent), id=resolve_agent_id(agent), propagate_request=True) + def _build_workflow(self, agent: SupportsAgentRun) -> Workflow: """Build the internal workflow for the AgentApprovalExecutor.""" - agent_executor = AgentExecutor(agent) + agent_executor = AgentExecutor(agent, context_mode=self._context_mode) request_info_executor = AgentRequestInfoExecutor(id="agent_request_info_executor") return ( diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py index 5ef4f7fe8c..1ccfed8f49 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_sequential.py @@ -38,7 +38,7 @@ confusion and to mirror how the concurrent builder uses explicit dispatcher/aggr import logging from collections.abc import Sequence -from typing import Any +from typing import Any, Literal from agent_framework import Message, SupportsAgentRun from agent_framework._workflows._agent_executor import ( @@ -143,6 +143,7 @@ class SequentialBuilder: *, participants: Sequence[SupportsAgentRun | Executor], checkpoint_storage: CheckpointStorage | None = None, + chain_only_agent_responses: bool = False, intermediate_outputs: bool = False, ) -> None: """Initialize the SequentialBuilder. @@ -150,10 +151,14 @@ class SequentialBuilder: Args: participants: Sequence of agent or executor instances to run sequentially. checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence. + chain_only_agent_responses: If True, only agent responses are chained between agents. + By default, the full conversation context is passed to the next agent. This also applies + to Executor -> Agent transitions if the executor sends `AgentExecutorResponse`. intermediate_outputs: If True, enables intermediate outputs from agent participants. """ self._participants: list[SupportsAgentRun | Executor] = [] self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage + self._chain_only_agent_responses: bool = chain_only_agent_responses self._request_info_enabled: bool = False self._request_info_filter: set[str] | None = None self._intermediate_outputs: bool = intermediate_outputs @@ -225,6 +230,10 @@ class SequentialBuilder: participants: list[Executor | SupportsAgentRun] = self._participants + context_mode: Literal["full", "last_agent", "custom"] | None = ( + "last_agent" if self._chain_only_agent_responses else None + ) + executors: list[Executor] = [] for p in participants: if isinstance(p, Executor): @@ -234,9 +243,9 @@ class SequentialBuilder: not self._request_info_filter or resolve_agent_id(p) in self._request_info_filter ): # Handle request info enabled agents - executors.append(AgentApprovalExecutor(p)) + executors.append(AgentApprovalExecutor(p, context_mode=context_mode)) else: - executors.append(AgentExecutor(p)) + executors.append(AgentExecutor(p, context_mode=context_mode)) else: raise TypeError(f"Participants must be SupportsAgentRun or Executor instances. Got {type(p).__name__}.") diff --git a/python/packages/orchestrations/tests/test_orchestration_request_info.py b/python/packages/orchestrations/tests/test_orchestration_request_info.py index 7d0acbc945..d618efcbf1 100644 --- a/python/packages/orchestrations/tests/test_orchestration_request_info.py +++ b/python/packages/orchestrations/tests/test_orchestration_request_info.py @@ -117,6 +117,7 @@ class TestAgentRequestInfoExecutor: agent_response = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, + full_conversation=agent_response.messages, ) ctx = MagicMock(spec=WorkflowContext) @@ -135,6 +136,7 @@ class TestAgentRequestInfoExecutor: original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, + full_conversation=agent_response.messages, ) response = AgentRequestInfoResponse.from_strings(["Additional input"]) @@ -161,6 +163,7 @@ class TestAgentRequestInfoExecutor: original_request = AgentExecutorResponse( executor_id="test_agent", agent_response=agent_response, + full_conversation=agent_response.messages, ) response = AgentRequestInfoResponse.approve() diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 67bcc1bb9e..0f000ef254 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -1,18 +1,20 @@ # Copyright (c) Microsoft. All rights reserved. from collections.abc import AsyncIterable, Awaitable -from typing import Any +from typing import Any, Literal, overload import pytest from agent_framework import ( AgentExecutorResponse, AgentResponse, AgentResponseUpdate, + AgentRunInputs, AgentSession, BaseAgent, Content, Executor, Message, + ResponseStream, TypeCompatibilityError, WorkflowContext, WorkflowRunState, @@ -25,26 +27,45 @@ from agent_framework.orchestrations import SequentialBuilder class _EchoAgent(BaseAgent): """Simple agent that appends a single assistant message with its name.""" - def run( # type: ignore[override] + @overload + def run( self, - messages: str | Message | list[str] | list[Message] | None = None, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, *, stream: bool = False, session: AgentSession | None = None, **kwargs: Any, - ) -> Awaitable[AgentResponse] | AsyncIterable[AgentResponseUpdate]: + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: if stream: - return self._run_stream() + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) async def _run() -> AgentResponse: return AgentResponse(messages=[Message("assistant", [f"{self.name} reply"])]) return _run() - async def _run_stream(self) -> AsyncIterable[AgentResponseUpdate]: - # Minimal async generator with one assistant update - yield AgentResponseUpdate(contents=[Content.from_text(text=f"{self.name} reply")]) - class _SummarizerExec(Executor): """Custom executor that summarizes by appending a short assistant message.""" @@ -251,3 +272,121 @@ async def test_sequential_builder_reusable_after_build_with_participants() -> No assert builder._participants[0] is a1 # type: ignore assert builder._participants[1] is a2 # type: ignore + + +# --------------------------------------------------------------------------- +# chain_only_agent_responses tests +# --------------------------------------------------------------------------- + + +class _CapturingAgent(BaseAgent): + """Agent that records the messages it received and returns a configurable reply.""" + + def __init__(self, *, reply_text: str = "reply", **kwargs: Any): + super().__init__(**kwargs) + self.reply_text = reply_text + self.last_messages: list[Message] = [] + + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[False] = ..., + session: AgentSession | None = ..., + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]]: ... + @overload + def run( + self, + messages: AgentRunInputs | None = ..., + *, + stream: Literal[True], + session: AgentSession | None = ..., + **kwargs: Any, + ) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ... + + def run( + self, + messages: AgentRunInputs | None = None, + *, + stream: bool = False, + session: AgentSession | None = None, + **kwargs: Any, + ) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: + captured: list[Message] = [] + if messages: + for m in messages: # type: ignore[union-attr] + if isinstance(m, Message): + captured.append(m) + elif isinstance(m, str): + captured.append(Message("user", [m])) + self.last_messages = captured + + if stream: + + async def _stream() -> AsyncIterable[AgentResponseUpdate]: + yield AgentResponseUpdate(contents=[Content.from_text(text=self.reply_text)]) + + return ResponseStream(_stream(), finalizer=AgentResponse.from_updates) + + async def _run() -> AgentResponse: + return AgentResponse(messages=[Message("assistant", [self.reply_text])]) + + return _run() + + +async def test_chain_only_agent_responses_false_passes_full_conversation() -> None: + """Default (chain_only_agent_responses=False) passes full conversation to the second agent.""" + a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply") + a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply") + + wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=False).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # Second agent should see full conversation: [user("hello"), assistant("A1 reply")] + seen = a2.last_messages + assert len(seen) == 2 + assert seen[0].role == "user" and "hello" in (seen[0].text or "") + assert seen[1].role == "assistant" and "A1 reply" in (seen[1].text or "") + + +async def test_chain_only_agent_responses_true_passes_only_agent_messages() -> None: + """chain_only_agent_responses=True passes only the previous agent's response messages.""" + a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply") + a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply") + + wf = SequentialBuilder(participants=[a1, a2], chain_only_agent_responses=True).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # Second agent should see only the assistant message: [assistant("A1 reply")] + seen = a2.last_messages + assert len(seen) == 1 + assert seen[0].role == "assistant" and "A1 reply" in (seen[0].text or "") + + +async def test_chain_only_agent_responses_three_agents() -> None: + """chain_only_agent_responses=True with three agents: each sees only the prior agent's reply.""" + a1 = _CapturingAgent(id="agent1", name="A1", reply_text="A1 reply") + a2 = _CapturingAgent(id="agent2", name="A2", reply_text="A2 reply") + a3 = _CapturingAgent(id="agent3", name="A3", reply_text="A3 reply") + + wf = SequentialBuilder(participants=[a1, a2, a3], chain_only_agent_responses=True).build() + + async for ev in wf.run("hello", stream=True): + if ev.type == "status" and ev.state == WorkflowRunState.IDLE: + break + + # a2 should see only A1's reply + assert len(a2.last_messages) == 1 + assert a2.last_messages[0].role == "assistant" and "A1 reply" in (a2.last_messages[0].text or "") + + # a3 should see only A2's reply + assert len(a3.last_messages) == 1 + assert a3.last_messages[0].role == "assistant" and "A2 reply" in (a3.last_messages[0].text or "") diff --git a/python/samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py b/python/samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py new file mode 100644 index 0000000000..2bef81ebe5 --- /dev/null +++ b/python/samples/03-workflows/orchestrations/sequential_chain_only_agent_responses.py @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import os + +from agent_framework import AgentResponseUpdate +from agent_framework.azure import AzureOpenAIResponsesClient +from agent_framework.orchestrations import SequentialBuilder +from azure.identity import AzureCliCredential +from dotenv import load_dotenv + +""" +Sample: Sequential workflow with chain_only_agent_responses=True + +Demonstrates SequentialBuilder with `chain_only_agent_responses=True`, which passes +only the previous agent's response (not the full conversation history) to the next +agent. This is useful when each agent should focus solely on refining or transforming +the prior agent's output without being influenced by earlier turns. + +In this sample, a writer agent produces a draft tagline, a translator agent translates +it into French (seeing only the writer's output, not the original user prompt), and a +reviewer agent evaluates the translation (seeing only the translator's output). + +Compare with `sequential_agents.py`, which uses the default behavior where the full +conversation context is passed to each agent. + +Prerequisites: +- AZURE_AI_PROJECT_ENDPOINT must be your Azure AI Foundry Agent Service (V2) project endpoint. +- Azure OpenAI configured for AzureOpenAIResponsesClient with required environment variables. +- Authentication via azure-identity. Use AzureCliCredential and run az login before executing the sample. +""" + +# Load environment variables from .env file +load_dotenv() + + +async def main() -> None: + # 1) Create agents + client = AzureOpenAIResponsesClient( + project_endpoint=os.environ["AZURE_AI_PROJECT_ENDPOINT"], + deployment_name=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + credential=AzureCliCredential(), + ) + + writer = client.as_agent( + instructions="You are a concise copywriter. Provide a single, punchy marketing sentence based on the prompt.", + name="writer", + ) + + translator = client.as_agent( + instructions="You are a translator. Translate the given text into French. Output only the translation.", + name="translator", + ) + + reviewer = client.as_agent( + instructions="You are a reviewer. Evaluate the quality of the marketing tagline.", + name="reviewer", + ) + + # 2) Build sequential workflow: writer -> translator -> reviewer + # chain_only_agent_responses=True means each agent sees only the previous agent's reply, + # not the full conversation history. + workflow = SequentialBuilder( + participants=[writer, translator, reviewer], + chain_only_agent_responses=True, + intermediate_outputs=True, + ).build() + + # 3) Run and collect outputs + last_agent: str | None = None + async for event in workflow.run("Write a tagline for a budget-friendly eBike.", stream=True): + if event.type == "output" and isinstance(event.data, AgentResponseUpdate): + if event.data.author_name != last_agent: + last_agent = event.data.author_name + print() + print(f"{last_agent}: ", end="", flush=True) + print(event.data.text, end="", flush=True) + + +if __name__ == "__main__": + asyncio.run(main()) From 7645ec4e07235f94c0dfb8c660aea994ad93a2fe Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:41:30 +0000 Subject: [PATCH 15/19] .NET: Improve visibility for AzureFunctions Workflows samples run tests in increase timeouts (#4820) * Reduce timeout flakiness for AzureFunctions Workflows samples run tests * Add more updates * Address PR comments * Address PR comments --- .../ExternalClientTests.cs | 2 +- .../SamplesValidationBase.cs | 68 +++++++++- .../AzureFunctionsTestHelper.cs | 117 ++++++++++++++++++ .../SamplesValidation.cs | 62 +--------- .../WorkflowSamplesValidation.cs | 35 ++---- 5 files changed, 196 insertions(+), 88 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs index 0e35d29750..6c200e9876 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/ExternalClientTests.cs @@ -21,7 +21,7 @@ public sealed class ExternalClientTests(ITestOutputHelper outputHelper) : IDispo { private static readonly TimeSpan s_defaultTimeout = Debugger.IsAttached ? TimeSpan.FromMinutes(5) - : TimeSpan.FromSeconds(30); + : TimeSpan.FromSeconds(60); private static readonly IConfiguration s_configuration = new ConfigurationBuilder() diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs index 5d541f614e..f5ecf0354d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/SamplesValidationBase.cs @@ -141,6 +141,10 @@ public abstract class SamplesValidationBase : IAsyncLifetime { string uniqueTaskHubName = $"{this.TaskHubPrefix}-{Guid.NewGuid():N}"[..^26]; + // Build the sample project first so that build failures are caught immediately + // instead of silently failing inside 'dotnet run' and causing a timeout. + await this.BuildSampleAsync(samplePath); + using BlockingCollection logsContainer = []; using Process appProcess = this.StartConsoleApp(samplePath, logsContainer, uniqueTaskHubName); @@ -154,7 +158,11 @@ public abstract class SamplesValidationBase : IAsyncLifetime } finally { - logsContainer.CompleteAdding(); + if (!logsContainer.IsAddingCompleted) + { + logsContainer.CompleteAdding(); + } + await this.StopProcessAsync(appProcess); } } @@ -329,12 +337,56 @@ public abstract class SamplesValidationBase : IAsyncLifetime } } + private async Task BuildSampleAsync(string samplePath) + { + this.OutputHelper.WriteLine($"Building sample at {samplePath}..."); + + ProcessStartInfo buildInfo = new() + { + FileName = "dotnet", + Arguments = $"build --framework {DotnetTargetFramework}", + WorkingDirectory = samplePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using Process buildProcess = new() { StartInfo = buildInfo }; + buildProcess.Start(); + + // Read both streams asynchronously to avoid deadlocks from filled pipe buffers + Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); + Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); + + using CancellationTokenSource buildCts = new(TimeSpan.FromMinutes(5)); + try + { + await buildProcess.WaitForExitAsync(buildCts.Token); + } + catch (OperationCanceledException) + { + buildProcess.Kill(entireProcessTree: true); + throw new TimeoutException($"Build timed out after 5 minutes for sample at {samplePath}."); + } + + await Task.WhenAll(stdoutTask, stderrTask); + + string stdout = stdoutTask.Result; + string stderr = stderrTask.Result; + if (buildProcess.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); + } + + this.OutputHelper.WriteLine($"Build completed for {samplePath}."); + } + private Process StartConsoleApp(string samplePath, BlockingCollection logs, string taskHubName) { ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run --framework {DotnetTargetFramework}", + Arguments = $"run --no-build --framework {DotnetTargetFramework}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, @@ -360,11 +412,21 @@ public abstract class SamplesValidationBase : IAsyncLifetime this.ConfigureAdditionalEnvironmentVariables(startInfo, SetAndLogEnvironmentVariable); - Process process = new() { StartInfo = startInfo }; + Process process = new() { StartInfo = startInfo, EnableRaisingEvents = true }; process.ErrorDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "err", LogLevel.Error, logs); process.OutputDataReceived += (sender, e) => this.HandleProcessOutput(e.Data, startInfo.FileName, "out", LogLevel.Information, logs); + // When the process exits unexpectedly (e.g. build failure), complete the log collection + // so that ReadLogLine returns null immediately instead of blocking until the test timeout. + process.Exited += (sender, e) => + { + if (!logs.IsAddingCompleted) + { + logs.CompleteAdding(); + } + }; + if (!process.Start()) { throw new InvalidOperationException("Failed to start the console app"); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs new file mode 100644 index 0000000000..b4150e6a58 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/AzureFunctionsTestHelper.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests; + +/// +/// Shared test helpers for Azure Functions integration tests. +/// +internal static class AzureFunctionsTestHelper +{ + private static readonly TimeSpan s_buildTimeout = TimeSpan.FromMinutes(5); + + /// + /// Builds the sample project, failing fast if the build fails or times out. + /// + internal static async Task BuildSampleAsync( + string samplePath, + string buildArgs, + ITestOutputHelper outputHelper) + { + outputHelper.WriteLine($"Building sample at {samplePath}..."); + + ProcessStartInfo buildInfo = new() + { + FileName = "dotnet", + Arguments = $"build {buildArgs}", + WorkingDirectory = samplePath, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + using Process buildProcess = new() { StartInfo = buildInfo }; + buildProcess.Start(); + + // Read both streams asynchronously to avoid deadlocks from filled pipe buffers + Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); + Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); + + using CancellationTokenSource buildCts = new(s_buildTimeout); + try + { + await buildProcess.WaitForExitAsync(buildCts.Token); + } + catch (OperationCanceledException) + { + buildProcess.Kill(entireProcessTree: true); + throw new TimeoutException($"Build timed out after {s_buildTimeout.TotalMinutes} minutes for sample at {samplePath}."); + } + + await Task.WhenAll(stdoutTask, stderrTask); + + string stdout = stdoutTask.Result; + string stderr = stderrTask.Result; + if (buildProcess.ExitCode != 0) + { + throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); + } + + outputHelper.WriteLine($"Build completed for {samplePath}."); + } + + /// + /// Polls the Azure Functions host until it responds to an HTTP HEAD request, + /// failing fast if the host process exits unexpectedly. + /// + internal static async Task WaitForFunctionsReadyAsync( + Process funcProcess, + string port, + HttpClient httpClient, + ITestOutputHelper outputHelper, + TimeSpan timeout, + string? samplePath = null) + { + outputHelper.WriteLine( + $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{port}/..."); + + using CancellationTokenSource cts = new(timeout); + while (true) + { + // Fail fast if the host process has exited (e.g. build or startup failure) + if (funcProcess.HasExited) + { + string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty; + throw new InvalidOperationException( + $"The Azure Functions host process exited unexpectedly with code {funcProcess.ExitCode}{context}."); + } + + try + { + using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{port}/"); + using HttpResponseMessage response = await httpClient.SendAsync(request); + outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); + if (response.IsSuccessStatusCode) + { + return; + } + } + catch (HttpRequestException) + { + // Expected when the app isn't yet ready + } + + try + { + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + string context = samplePath != null ? $" for sample '{samplePath}'" : string.Empty; + throw new TimeoutException( + $"Timeout waiting for 'Azure Functions Core Tools is ready'{context}"); + } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs index c416fb6a2a..b15f6e8f42 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/SamplesValidation.cs @@ -803,7 +803,8 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi private async Task RunSampleTestAsync(string samplePath, Func, Task> testAction) { // Build the sample project first (it may not have been built as part of the solution) - await this.BuildSampleAsync(samplePath); + await AzureFunctionsTestHelper.BuildSampleAsync( + samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper); // Start the Azure Functions app List logsContainer = []; @@ -811,7 +812,8 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi try { // Wait for the app to be ready - await this.WaitForAzureFunctionsAsync(); + await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync( + funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath); // Run the test await testAction(logsContainer); @@ -824,38 +826,6 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi private sealed record OutputLog(DateTime Timestamp, LogLevel Level, string Message); - private async Task BuildSampleAsync(string samplePath) - { - this._outputHelper.WriteLine($"Building sample at {samplePath}..."); - - ProcessStartInfo buildInfo = new() - { - FileName = "dotnet", - Arguments = $"build -f {s_dotnetTargetFramework} -c {BuildConfiguration}", - WorkingDirectory = samplePath, - UseShellExecute = false, - RedirectStandardOutput = true, - RedirectStandardError = true, - }; - - using Process buildProcess = new() { StartInfo = buildInfo }; - buildProcess.Start(); - - // Read both streams asynchronously to avoid deadlocks from filled pipe buffers - Task stdoutTask = buildProcess.StandardOutput.ReadToEndAsync(); - Task stderrTask = buildProcess.StandardError.ReadToEndAsync(); - await buildProcess.WaitForExitAsync(); - - string stderr = await stderrTask; - if (buildProcess.ExitCode != 0) - { - string stdout = await stdoutTask; - throw new InvalidOperationException($"Failed to build sample at {samplePath}:\n{stdout}\n{stderr}"); - } - - this._outputHelper.WriteLine($"Build completed for {samplePath}."); - } - private Process StartFunctionApp(string samplePath, List logs) { ProcessStartInfo startInfo = new() @@ -919,30 +889,6 @@ public sealed class SamplesValidation(ITestOutputHelper outputHelper) : IAsyncLi return process; } - private async Task WaitForAzureFunctionsAsync() - { - this._outputHelper.WriteLine( - $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/..."); - await this.WaitForConditionAsync( - condition: async () => - { - try - { - using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/"); - using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request); - this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); - return response.IsSuccessStatusCode; - } - catch (HttpRequestException) - { - // Expected when the app isn't yet ready - return false; - } - }, - message: "Azure Functions Core Tools is ready", - timeout: s_functionsReadyTimeout); - } - private async Task WaitForOrchestrationCompletionAsync(Uri statusUri) { using CancellationTokenSource timeoutCts = new(s_orchestrationTimeout); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs index efb02b1aff..da075ea107 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/WorkflowSamplesValidation.cs @@ -36,7 +36,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : private static bool s_infrastructureStarted; private static readonly TimeSpan s_orchestrationTimeout = TimeSpan.FromMinutes(1); - // In CI, `dotnet run` builds the Functions project from scratch before the host starts, so 60s is not enough. + // Timeout for the Azure Functions host to become ready after building. private static readonly TimeSpan s_functionsReadyTimeout = TimeSpan.FromSeconds(180); private static readonly string s_samplesPath = Path.GetFullPath( @@ -425,11 +425,17 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : private async Task RunSampleTestAsync(string samplePath, bool requiresOpenAI, Func, Task> testAction) { + // Build the sample project first (it may not have been built as part of the solution) + await AzureFunctionsTestHelper.BuildSampleAsync( + samplePath, $"-f {s_dotnetTargetFramework} -c {BuildConfiguration}", this._outputHelper); + + // Start the Azure Functions app List logsContainer = []; using Process funcProcess = this.StartFunctionApp(samplePath, logsContainer, requiresOpenAI); try { - await this.WaitForAzureFunctionsAsync(); + await AzureFunctionsTestHelper.WaitForFunctionsReadyAsync( + funcProcess, AzureFunctionsPort, s_sharedHttpClient, this._outputHelper, s_functionsReadyTimeout, samplePath); await testAction(logsContainer); } finally @@ -443,7 +449,7 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : ProcessStartInfo startInfo = new() { FileName = "dotnet", - Arguments = $"run -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", + Arguments = $"run --no-build -f {s_dotnetTargetFramework} -c {BuildConfiguration} --port {AzureFunctionsPort}", WorkingDirectory = samplePath, UseShellExecute = false, RedirectStandardOutput = true, @@ -504,29 +510,6 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) : return process; } - private async Task WaitForAzureFunctionsAsync() - { - this._outputHelper.WriteLine( - $"Waiting for Azure Functions Core Tools to be ready at http://localhost:{AzureFunctionsPort}/..."); - await this.WaitForConditionAsync( - condition: async () => - { - try - { - using HttpRequestMessage request = new(HttpMethod.Head, $"http://localhost:{AzureFunctionsPort}/"); - using HttpResponseMessage response = await s_sharedHttpClient.SendAsync(request); - this._outputHelper.WriteLine($"Azure Functions Core Tools response: {response.StatusCode}"); - return response.IsSuccessStatusCode; - } - catch (HttpRequestException) - { - return false; - } - }, - message: "Azure Functions Core Tools is ready", - timeout: s_functionsReadyTimeout); - } - private async Task RunCommandAsync(string command, string[] args) { ProcessStartInfo startInfo = new() From 6803058e3625940b981e1ed4655d36d14455f158 Mon Sep 17 00:00:00 2001 From: westey <164392973+westey-m@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:41:34 +0000 Subject: [PATCH 16/19] .NET: Obsolete the V1 helper methods and migrate samples using it where possible (#4795) * Obsolete the V1 helper methods and migrate samples using it where possible * Address PR comments --- .../Program.cs | 2 + .../Agent_Step07_AsMcpTool.csproj | 4 +- .../Agents/Agent_Step07_AsMcpTool/Program.cs | 11 ++- .../Agent_Step15_DeepResearch/Program.cs | 2 + .../FoundryAgent_Hosted_MCP.csproj | 4 +- .../FoundryAgent_Hosted_MCP/Program.cs | 14 ++-- .../Agents/FoundryAgent/FoundryAgent.csproj | 4 +- .../Agents/FoundryAgent/Program.cs | 69 ++++++++++--------- .../A2AServer/A2AServer.csproj | 4 +- .../A2AServer/HostAgentFactory.cs | 11 ++- .../A2AClientServer/A2AServer/Program.cs | 18 ++--- .../05-end-to-end/A2AClientServer/README.md | 6 +- .../PersistentAgentsClientExtensions.cs | 8 +++ .../AzureAIAgentsPersistentCreateTests.cs | 2 + .../PersistentAgentsClientExtensionsTests.cs | 2 + 15 files changed, 88 insertions(+), 73 deletions(-) diff --git a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs index 691fb20328..0603933dbf 100644 --- a/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs +++ b/dotnet/samples/02-agents/AgentProviders/Agent_With_AzureAIAgentsPersistent/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions + // This sample shows how to create and use a simple AI agent with Azure Foundry Agents as the backend. using Azure.AI.Agents.Persistent; diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj index db776afd1e..5239225499 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Agent_Step07_AsMcpTool.csproj @@ -10,14 +10,14 @@ - + - + diff --git a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs index d621227ea0..7bc6478968 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step07_AsMcpTool/Program.cs @@ -2,7 +2,7 @@ // This sample shows how to expose an AI agent as an MCP tool. -using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.DependencyInjection; @@ -15,18 +15,15 @@ var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYME // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); +var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); -// Create a server side persistent agent -var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( +// Create a server side agent and expose it as an AIAgent. +AIAgent agent = await aiProjectClient.CreateAIAgentAsync( model: deploymentName, instructions: "You are good at telling jokes, and you always start each joke with 'Aye aye, captain!'.", name: "Joker", description: "An agent that tells jokes."); -// Retrieve the server side persistent agent as an AIAgent. -AIAgent agent = await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); - // Convert the agent to an AIFunction and then to an MCP tool. // The agent name and description will be used as the mcp tool name and description. McpServerTool tool = McpServerTool.Create(agent.AsAIFunction()); diff --git a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs index cbbc327948..7a76f73455 100644 --- a/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs +++ b/dotnet/samples/02-agents/Agents/Agent_Step15_DeepResearch/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - sample uses deprecated PersistentAgentsClientExtensions + // This sample shows how to create an Azure AI Foundry Agent with the Deep Research Tool. using Azure.AI.Agents.Persistent; diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj index d40e93232b..d861331d9f 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj @@ -9,12 +9,12 @@ - + - + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs index e34c3d932e..e91ed4d15a 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/Program.cs @@ -4,7 +4,7 @@ // In this case the Azure Foundry Agents service will invoke any MCP tools as required. MCP tools are not invoked by the Agent Framework. // The sample first shows how to use MCP tools with auto approval, and then how to set up a tool that requires approval before it can be invoked and how to approve such a tool. -using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -16,7 +16,7 @@ var model = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); +var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); // **** MCP Tool with Auto Approval **** // ************************************* @@ -31,8 +31,8 @@ var mcpTool = new HostedMcpServerTool( ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire }; -// Create a server side persistent agent with the mcp tool, and expose it as an AIAgent. -AIAgent agent = await persistentAgentsClient.CreateAIAgentAsync( +// Create a server side agent with the mcp tool, and expose it as an AIAgent. +AIAgent agent = await aiProjectClient.CreateAIAgentAsync( model: model, options: new() { @@ -49,7 +49,7 @@ AgentSession session = await agent.CreateSessionAsync(); Console.WriteLine(await agent.RunAsync("Please summarize the Azure AI Agent documentation related to MCP Tool calling?", session)); // Cleanup for sample purposes. -await persistentAgentsClient.Administration.DeleteAgentAsync(agent.Id); +aiProjectClient.Agents.DeleteAgent(agent.Name); // **** MCP Tool with Approval Required **** // ***************************************** @@ -64,8 +64,8 @@ var mcpToolWithApproval = new HostedMcpServerTool( ApprovalMode = HostedMcpServerToolApprovalMode.AlwaysRequire }; -// Create an agent based on Azure OpenAI Responses as the backend. -AIAgent agentWithRequiredApproval = await persistentAgentsClient.CreateAIAgentAsync( +// Create an agent with the MCP tool that requires approval. +AIAgent agentWithRequiredApproval = await aiProjectClient.CreateAIAgentAsync( model: model, options: new() { diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj index 30227d3f20..a7648b7a10 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/FoundryAgent.csproj @@ -9,13 +9,13 @@ - + - + diff --git a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs index f322bb882d..589eca2bbc 100644 --- a/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs +++ b/dotnet/samples/03-workflows/Agents/FoundryAgent/Program.cs @@ -1,6 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. -using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; @@ -20,60 +20,63 @@ public static class Program { private static async Task Main() { - // Set up the Azure OpenAI client + // Set up the Azure AI Project client var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o-mini"; - var persistentAgentsClient = new PersistentAgentsClient(endpoint, new AzureCliCredential()); + var aiProjectClient = new AIProjectClient(new Uri(endpoint), new AzureCliCredential()); // Create agents - AIAgent frenchAgent = await GetTranslationAgentAsync("French", persistentAgentsClient, deploymentName); - AIAgent spanishAgent = await GetTranslationAgentAsync("Spanish", persistentAgentsClient, deploymentName); - AIAgent englishAgent = await GetTranslationAgentAsync("English", persistentAgentsClient, deploymentName); + AIAgent frenchAgent = await CreateTranslationAgentAsync("French", aiProjectClient, deploymentName); + AIAgent spanishAgent = await CreateTranslationAgentAsync("Spanish", aiProjectClient, deploymentName); + AIAgent englishAgent = await CreateTranslationAgentAsync("English", aiProjectClient, deploymentName); - // Build the workflow by adding executors and connecting them - var workflow = new WorkflowBuilder(frenchAgent) - .AddEdge(frenchAgent, spanishAgent) - .AddEdge(spanishAgent, englishAgent) - .Build(); - - // Execute the workflow - await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); - // Must send the turn token to trigger the agents. - // The agents are wrapped as executors. When they receive messages, - // they will cache the messages and only start processing when they receive a TurnToken. - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); - await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + try { - if (evt is AgentResponseUpdateEvent executorComplete) + // Build the workflow by adding executors and connecting them + var workflow = new WorkflowBuilder(frenchAgent) + .AddEdge(frenchAgent, spanishAgent) + .AddEdge(spanishAgent, englishAgent) + .Build(); + + // Execute the workflow + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, new ChatMessage(ChatRole.User, "Hello World!")); + // Must send the turn token to trigger the agents. + // The agents are wrapped as executors. When they receive messages, + // they will cache the messages and only start processing when they receive a TurnToken. + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) { - Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + if (evt is AgentResponseUpdateEvent executorComplete) + { + Console.WriteLine($"{executorComplete.ExecutorId}: {executorComplete.Data}"); + } } } - - // Cleanup the agents created for the sample. - await persistentAgentsClient.Administration.DeleteAgentAsync(frenchAgent.Id); - await persistentAgentsClient.Administration.DeleteAgentAsync(spanishAgent.Id); - await persistentAgentsClient.Administration.DeleteAgentAsync(englishAgent.Id); + finally + { + // Cleanup the agents created for the sample. + await aiProjectClient.Agents.DeleteAgentAsync(frenchAgent.Name); + await aiProjectClient.Agents.DeleteAgentAsync(spanishAgent.Name); + await aiProjectClient.Agents.DeleteAgentAsync(englishAgent.Name); + } } /// /// Creates a translation agent for the specified target language. /// /// The target language for translation - /// The PersistentAgentsClient to create the agent + /// The to create the agent with. /// The model to use for the agent /// A ChatClientAgent configured for the specified language - private static async Task GetTranslationAgentAsync( + private static async Task CreateTranslationAgentAsync( string targetLanguage, - PersistentAgentsClient persistentAgentsClient, + AIProjectClient aiProjectClient, string model) { - var agentMetadata = await persistentAgentsClient.Administration.CreateAgentAsync( - model: model, + return await aiProjectClient.CreateAIAgentAsync( name: $"{targetLanguage} Translator", + model: model, instructions: $"You are a translation assistant that translates the provided text to {targetLanguage}."); - - return await persistentAgentsClient.GetAIAgentAsync(agentMetadata.Value.Id); } } diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj index be5ff472c1..5a7ef20208 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/A2AServer.csproj @@ -9,7 +9,7 @@ - + @@ -23,7 +23,7 @@ - + diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs index 79c3060d90..584b7db422 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/HostAgentFactory.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using A2A; -using Azure.AI.Agents.Persistent; +using Azure.AI.Projects; using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Extensions.AI; @@ -12,16 +12,15 @@ namespace A2AServer; internal static class HostAgentFactory { - internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string assistantId, IList? tools = null) + internal static async Task<(AIAgent, AgentCard)> CreateFoundryHostAgentAsync(string agentType, string model, string endpoint, string agentName, IList? tools = null) { // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. - var persistentAgentsClient = new PersistentAgentsClient(endpoint, new DefaultAzureCredential()); - PersistentAgent persistentAgent = await persistentAgentsClient.Administration.GetAgentAsync(assistantId); + var aiProjectClient = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential()); - AIAgent agent = await persistentAgentsClient - .GetAIAgentAsync(persistentAgent.Id, chatOptions: new() { Tools = tools }); + AIAgent agent = await aiProjectClient + .GetAIAgentAsync(agentName, tools: tools); AgentCard agentCard = agentType.ToUpperInvariant() switch { diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs index b8a10ac647..f1c0b966fe 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs +++ b/dotnet/samples/05-end-to-end/A2AClientServer/A2AServer/Program.cs @@ -8,16 +8,16 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -string agentId = string.Empty; +string agentName = string.Empty; string agentType = string.Empty; for (var i = 0; i < args.Length; i++) { - if (args[i].StartsWith("--agentId", StringComparison.InvariantCultureIgnoreCase) && i + 1 < args.Length) + if (args[i].Equals("--agentName", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { - agentId = args[++i]; + agentName = args[++i]; } - else if (args[i].StartsWith("--agentType", StringComparison.InvariantCultureIgnoreCase) && i + 1 < args.Length) + else if (args[i].Equals("--agentType", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) { agentType = args[++i]; } @@ -50,13 +50,13 @@ IList tools = AIAgent hostA2AAgent; AgentCard hostA2AAgentCard; -if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentId)) +if (!string.IsNullOrEmpty(endpoint) && !string.IsNullOrEmpty(agentName)) { (hostA2AAgent, hostA2AAgentCard) = agentType.ToUpperInvariant() switch { - "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentId, tools), - "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentId), - "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentId), + "INVOICE" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName, tools), + "POLICY" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), + "LOGISTICS" => await HostAgentFactory.CreateFoundryHostAgentAsync(agentType, model, endpoint, agentName), _ => throw new ArgumentException($"Unsupported agent type: {agentType}"), }; } @@ -101,7 +101,7 @@ else if (!string.IsNullOrEmpty(apiKey)) } else { - throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentId must be provided"); + throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided"); } var a2aTaskManager = app.MapA2A( diff --git a/dotnet/samples/05-end-to-end/A2AClientServer/README.md b/dotnet/samples/05-end-to-end/A2AClientServer/README.md index eea3763791..cff5b40e2d 100644 --- a/dotnet/samples/05-end-to-end/A2AClientServer/README.md +++ b/dotnet/samples/05-end-to-end/A2AClientServer/README.md @@ -90,15 +90,15 @@ $env:AZURE_AI_PROJECT_ENDPOINT="https://ai-foundry-your-project.services.ai.azur Use the following commands to run each A2A server ```bash -dotnet run --urls "http://localhost:5000;https://localhost:5010" --agentId "" --agentType "invoice" --no-build +dotnet run --urls "http://localhost:5000;https://localhost:5010" --agentName "" --agentType "invoice" --no-build ``` ```bash -dotnet run --urls "http://localhost:5001;https://localhost:5011" --agentId "" --agentType "policy" --no-build +dotnet run --urls "http://localhost:5001;https://localhost:5011" --agentName "" --agentType "policy" --no-build ``` ```bash -dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentId "" --agentType "logistics" --no-build +dotnet run --urls "http://localhost:5002;https://localhost:5012" --agentName "" --agentType "logistics" --no-build ``` ### Testing the Agents using the Rest Client diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs index 660e874711..020439a8f5 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI.Persistent/PersistentAgentsClientExtensions.cs @@ -19,6 +19,7 @@ public static class PersistentAgentsClientExtensions /// Provides a way to customize the creation of the underlying used by the agent. /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static ChatClientAgent AsAIAgent( this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, @@ -43,6 +44,7 @@ public static class PersistentAgentsClientExtensions /// Provides a way to customize the creation of the underlying used by the agent. /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static ChatClientAgent AsAIAgent( this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, @@ -93,6 +95,7 @@ public static class PersistentAgentsClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the persistent agent. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static async Task GetAIAgentAsync( this PersistentAgentsClient persistentAgentsClient, string agentId, @@ -125,6 +128,7 @@ public static class PersistentAgentsClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static ChatClientAgent AsAIAgent( this PersistentAgentsClient persistentAgentsClient, Response persistentAgentResponse, @@ -150,6 +154,7 @@ public static class PersistentAgentsClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static ChatClientAgent AsAIAgent( this PersistentAgentsClient persistentAgentsClient, PersistentAgent persistentAgentMetadata, @@ -211,6 +216,7 @@ public static class PersistentAgentsClientExtensions /// A instance that can be used to perform operations on the persistent agent. /// Thrown when or is . /// Thrown when is empty or whitespace. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static async Task GetAIAgentAsync( this PersistentAgentsClient persistentAgentsClient, string agentId, @@ -256,6 +262,7 @@ public static class PersistentAgentsClientExtensions /// An optional to use for resolving services required by the instances being invoked. /// The to monitor for cancellation requests. The default is . /// A instance that can be used to perform operations on the newly created agent. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static async Task CreateAIAgentAsync( this PersistentAgentsClient persistentAgentsClient, string model, @@ -306,6 +313,7 @@ public static class PersistentAgentsClientExtensions /// A instance that can be used to perform operations on the newly created agent. /// Thrown when or or is . /// Thrown when is empty or whitespace. + [Obsolete("Please use the latest Foundry Agents service via the Microsoft.Agents.AI.AzureAI package.")] public static async Task CreateAIAgentAsync( this PersistentAgentsClient persistentAgentsClient, string model, diff --git a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs index b5098ddd74..de9a41e1a0 100644 --- a/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs +++ b/dotnet/tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistentCreateTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - testing deprecated PersistentAgentsClientExtensions + using System; using System.Diagnostics; using System.IO; diff --git a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs index 0d78b9ff06..f14682f221 100644 --- a/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Extensions/PersistentAgentsClientExtensionsTests.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable CS0618 // Type or member is obsolete - testing deprecated PersistentAgentsClientExtensions + using System; using System.ClientModel.Primitives; using System.Collections.Generic; From 9dfe7c40ca67f0c597639bae50d22fc20b76028f Mon Sep 17 00:00:00 2001 From: Ben Thomas Date: Fri, 20 Mar 2026 13:20:59 -0700 Subject: [PATCH 17/19] Add ADR-0020: Foundry Evals integration (#4731) * Add ADR-0020: Foundry Evals integration design Captures the design for integrating Azure AI Foundry Evaluations with agent-framework. Key decisions: - EvalItem with conversation (list[Message]) as single source of truth - query/response derived from configurable conversation split strategies - Tools as list[FunctionTool] (including auto-extracted MCP tools) - FoundryEvals provider with auto-detection of evaluator capabilities - LocalEvaluator with @function_evaluator decorator for local checks - Consistent Python/C# APIs: evaluate_agent, evaluate_workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mark ADR 0020 Foundry Evals as accepted Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: alliscode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../0020-foundry-evals-integration.md | 815 ++++++++++++++++++ 1 file changed, 815 insertions(+) create mode 100644 docs/decisions/0020-foundry-evals-integration.md diff --git a/docs/decisions/0020-foundry-evals-integration.md b/docs/decisions/0020-foundry-evals-integration.md new file mode 100644 index 0000000000..f5b5db4db5 --- /dev/null +++ b/docs/decisions/0020-foundry-evals-integration.md @@ -0,0 +1,815 @@ +--- +status: accepted +contact: bentho +date: 2026-02-27 +deciders: bentho, markwallace-microsoft, westey-m +consulted: Pratyush Mishra, Shivam Shrivastava, Manni Arora (Centrica eval scenario) +informed: Agent Framework team, Foundry Evals team +--- + +# Agent Evaluation Architecture with Azure AI Foundry Integration + +## Context and Problem Statement + +Azure AI Foundry provides a rich evaluation service for AI agents — built-in evaluators for agent behavior (task adherence, intent resolution), tool usage (tool call accuracy, tool selection), quality (coherence, fluency, relevance), and safety (violence, self-harm, prohibited actions). Results are viewable in the Foundry portal with dashboards and comparison views. + +However, using Foundry Evals with an agent-framework agent today requires significant manual effort. Developers must: + +1. Transform agent-framework's `Message`/`Content` types into the OpenAI-style agent message schema that Foundry evaluators expect +2. Map tool definitions from agent-framework's `FunctionTool` format to evaluator-compatible schemas +3. Manually wire up the correct Foundry data source type (`azure_ai_traces`, `jsonl`, `azure_ai_target_completions`, etc.) depending on their scenario +4. Handle App Insights trace ID queries, response ID collection, and eval polling + +Additionally, evaluation is a concern that extends beyond any single provider. Developers may want to use local evaluators (LLM-as-judge, regex, keyword matching), third-party evaluation libraries, or multiple providers in combination. The architecture must support this without creating a Foundry-specific lock-in at the API level. + +### Functional Requirements for Agent Evaluation + +- **Single agents and workflows.** Evaluate both individual agent responses and multi-agent workflow results, with per-agent breakdown to pinpoint underperformance. +- **One-shot and multi-turn conversations.** Capture full conversation trajectories — including tool calls and results — not just final query/response pairs. +- **Conversation factoring.** Support splitting conversations into query/response in multiple ways (last turn, full trajectory, per-turn) because different factorings measure different things. +- **Multiple providers, mix and match.** Run Foundry LLM-as-judge evaluators alongside fast local checks and custom evaluators on the same data, without restructuring code. +- **Third-party extensibility.** Any evaluation library can participate by implementing the `Evaluator` protocol (Python) or `IAgentEvaluator` interface (.NET). No predetermined list of supported libraries — the protocol is intentionally simple (`evaluate(items) → results`) so that wrappers for libraries like DeepEval, RAGAS, or Promptfoo are straightforward to write. +- **Bring your own evaluator.** Creating a custom evaluator should be as simple as writing a function. +- **Evaluate without re-running.** Evaluate existing responses from logs or previous runs without invoking the agent again. + +## Decision Drivers + +- **Zero-friction evaluation**: Developers should go from "I have an agent" to "I have eval results" with minimal code. +- **Provider-agnostic API**: Core evaluation capabilities must not be tied to any specific provider. Provider configuration should be separate from the evaluation call. +- **Lowest concept count**: Introduce the fewest possible new types, abstractions, and APIs for developers to learn. +- **Leverage existing knowledge**: The framework already knows which agents exist, what tools they have, and what conversations occurred. Evals should use this automatically rather than requiring the developer to re-specify it. +- **Foundry-native results**: When using Foundry, results should be viewable in the Foundry portal with dashboards and comparison views. +- **Progressive disclosure**: Simple scenarios should be near-zero code. Advanced scenarios should build on the same primitives. +- **Cross-language parity**: Design must be implementable in both Python and .NET. + +## Considered Options + +1. **Provider-specific functions** — Build Foundry-specific helper functions (`evaluate_agent()`, etc.) directly in the Azure package. All eval functions take Foundry connection parameters. +2. **Evaluator protocol with shared orchestration** — Define a provider-agnostic `Evaluator` protocol in the base agent library (`agent_framework` in Python, `Microsoft.Agents.AI` in .NET). Orchestration functions live alongside it. Providers implement the protocol. +3. **Full eval framework** — Build comprehensive eval infrastructure including custom evaluator definitions, scoring profiles, and reporting inside agent-framework. + +## Decision Outcome + +Proposed option: "Evaluator protocol with shared orchestration", because it delivers the low-friction developer experience, supports multiple providers without API changes, and keeps the concept count low. + +### Usage Examples + +#### Evaluate an agent + +The agent is invoked once per query by default. For statistically meaningful evaluation, provide multiple diverse queries. For measuring **consistency** (does the same query produce reliable results?), use `num_repetitions` to run each query N times independently: + +**Python:** + +```python +evals = FoundryEvals( + project_client=client, + model_deployment="gpt-4o", + evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE], +) + +results = await evaluate_agent( + agent=my_agent, + queries=[ + "What's the weather in Seattle?", + "Plan a weekend trip to Portland", + "What restaurants are near Pike Place?", + ], + evaluators=evals, +) +for r in results: + r.assert_passed() +``` + +**C#:** + +```csharp +var evals = new FoundryEvals(chatConfiguration, FoundryEvals.Relevance, FoundryEvals.Coherence); + +AgentEvaluationResults results = await agent.EvaluateAsync( + new[] { + "What's the weather in Seattle?", + "Plan a weekend trip to Portland", + "What restaurants are near Pike Place?", + }, + evals); + +results.AssertAllPassed(); +``` + +`evaluate_agent` returns one `EvalResults` per evaluator. Each result contains per-item scores with the evaluated response for auditing: + +``` +# results[0] (FoundryEvals) +EvalResults(status="completed", passed=3, failed=0, total=3) + items[0]: EvalItemResult( + query="What's the weather in Seattle?", + response="It's currently 72°F and sunny in Seattle.", + scores={"relevance": 5, "coherence": 5}) + items[1]: EvalItemResult( + query="Plan a weekend trip to Portland", + response="Here's a 2-day Portland itinerary...", + scores={"relevance": 4, "coherence": 5}) + items[2]: EvalItemResult( + query="What restaurants are near Pike Place?", + response="Top restaurants near Pike Place Market: ...", + scores={"relevance": 5, "coherence": 4}) +``` + +#### Measure consistency with repetitions + +Run each query multiple times to detect non-deterministic behavior: + +**Python:** + +```python +results = await evaluate_agent( + agent=my_agent, + queries=["What's the weather in Seattle?"], + evaluators=evals, + num_repetitions=3, # each query runs 3 times independently +) +# results contain 3 items (1 query × 3 repetitions) +``` + +**C#:** + +```csharp +AgentEvaluationResults results = await agent.EvaluateAsync( + new[] { "What's the weather in Seattle?" }, + evals, + numRepetitions: 3); // each query runs 3 times independently +// results contain 3 items (1 query × 3 repetitions) +``` + +#### Evaluate a response you already have + +When you already have agent responses, pass them directly to skip re-running the agent. Each query is paired with its corresponding response: + +**Python:** + +```python +queries = ["What's the weather?", "What's the capital of France?"] +responses = [await agent.run([Message("user", [q])]) for q in queries] + +results = await evaluate_agent( + responses=responses, + evaluators=evals, +) +``` + +**C#:** + +```csharp +var queries = new[] { "What's the weather?" }; +var responses = new List(); +foreach (var q in queries) + responses.Add(await agent.RunAsync(new[] { new ChatMessage(ChatRole.User, q) })); + +AgentEvaluationResults results = await agent.EvaluateAsync( + responses: responses, + evals); +``` + +Each `AgentResponse` already contains the conversation (query + response), so the evaluator extracts query/response from the conversation. When you pass `responses` without `queries`, the conversation is the source of truth. + +#### Evaluate with conversation split strategies + +By default, evaluators see only the last turn (final user message → final assistant response). For multi-turn conversations, you can control how the conversation is factored for evaluation: + +**Python:** + +```python +results = await evaluate_agent( + agent=agent, + queries=["Plan a 3-day trip to Paris"], + evaluators=evals, + conversation_split=ConversationSplit.FULL, # evaluate entire trajectory +) + +# Or per-turn: each user→assistant exchange scored independently +results = await evaluate_agent( + agent=agent, + queries=["Plan a 3-day trip to Paris"], + evaluators=evals, + conversation_split=ConversationSplit.PER_TURN, +) +``` + +**C#:** + +```csharp +// Full conversation as context +AgentEvaluationResults results = await agent.EvaluateAsync( + new[] { "Plan a 3-day trip to Paris" }, + evals, + splitter: ConversationSplitters.Full); + +// Per-turn splitting +var items = EvalItem.PerTurnItems(conversation); // one EvalItem per user turn +var results = await evals.EvaluateAsync(items); +``` + +With `PER_TURN`, a 3-turn conversation produces 3 scored items: + +``` +EvalResults(status="completed", passed=3, failed=0, total=3) + items[0]: query="Plan a 3-day trip to Paris" scores={"relevance": 5} + items[1]: query="What about restaurants?" scores={"relevance": 4} + items[2]: query="Make it budget-friendly" scores={"relevance": 5} +``` + +#### Evaluate a multi-agent workflow + +**Python:** + +```python +result = await workflow.run("Plan a trip to Paris") +eval_results = await evaluate_workflow( + workflow=workflow, + workflow_result=result, + evaluators=evals, +) + +for r in eval_results: + print(f" overall: {r.passed}/{r.total}") + for name, sub in r.sub_results.items(): + print(f" {name}: {sub.passed}/{sub.total}") +``` + +**C#:** + +```csharp +WorkflowRunResult result = await workflow.RunAsync("Plan a trip to Paris"); + +IReadOnlyList evalResults = await result.EvaluateAsync(evals); + +foreach (var r in evalResults) +{ + Console.WriteLine($" overall: {r.Passed}/{r.Total}"); + foreach (var (name, sub) in r.SubResults) + Console.WriteLine($" {name}: {sub.Passed}/{sub.Total}"); +} +``` + +Workflows return one result per evaluator, with sub-results per agent in the workflow: + +``` +EvalResults(status="completed", passed=2, failed=0, total=2) + sub_results: + "planner": EvalResults(passed=1, total=1) + "researcher": EvalResults(passed=1, total=1) +``` + +#### Mix multiple providers + +**Python:** + +```python +@evaluator +def is_helpful(response: str) -> bool: + return len(response.split()) > 10 + +foundry = FoundryEvals( + project_client=client, + model_deployment="gpt-4o", + evaluators=[FoundryEvals.RELEVANCE, FoundryEvals.COHERENCE], +) + +results = await evaluate_agent( + agent=agent, + queries=queries, + evaluators=[is_helpful, keyword_check("weather"), foundry], +) +``` + +**C#:** + +```csharp +IReadOnlyList results = await agent.EvaluateAsync( + queries, + evaluators: new IAgentEvaluator[] + { + new LocalEvaluator( + EvalChecks.KeywordCheck("weather"), + FunctionEvaluator.Create("is_helpful", (string r) => r.Split(' ').Length > 10)), + new FoundryEvals(chatConfiguration, FoundryEvals.Relevance, FoundryEvals.Coherence), + }); +``` + +Multiple evaluators return one result each — `results[0]` is the local evaluator, `results[1]` is Foundry. + +#### Custom function evaluators + +**Python:** + +```python +@evaluator +def mentions_city(response: str, expected_output: str) -> bool: + return expected_output.lower() in response.lower() + +@evaluator +def used_tools(conversation: list, tools: list) -> float: + # ... scoring logic + return score + +local = LocalEvaluator(mentions_city, used_tools) +``` + +`@evaluator` uses **parameter name injection** — the function's parameter names determine what data it receives from the `EvalItem`. Supported names: `query`, `response`, `expected`, `expected_tool_calls`, `conversation`, `tools`, `context`. Any combination is valid. + +**C#:** + +```csharp +var local = new LocalEvaluator( + FunctionEvaluator.Create("mentions_city", + (EvalItem item) => item.ExpectedOutput != null + && item.Response.Contains(item.ExpectedOutput, StringComparison.OrdinalIgnoreCase)), + FunctionEvaluator.Create("is_concise", + (string response) => response.Split(' ').Length < 500)); +``` + +## What To Build + +### Core: Evaluator Protocol + +A runtime-checkable protocol that any evaluation provider implements: + +```python +@runtime_checkable +class Evaluator(Protocol): + name: str + + async def evaluate( + self, items: Sequence[EvalItem], *, eval_name: str = "Agent Framework Eval" + ) -> EvalResults: ... +``` + +The protocol is minimal — just `name` and `evaluate()`. + +### Core: EvalItem + +Provider-agnostic data format for items to evaluate: + +```python +@dataclass +class ExpectedToolCall: + name: str # Tool/function name + arguments: dict[str, Any] | None = None # None = don't check args + +@dataclass +class EvalItem: + conversation: list[Message] # Single source of truth + tools: list[FunctionTool] | None = None # Agent's available tools + context: str | None = None + expected_output: str | None = None # Ground-truth for comparison + expected_tool_calls: list[ExpectedToolCall] | None = None + split_strategy: ConversationSplitter | None = None + + query: str # property — derived from conversation split + response: str # property — derived from conversation split +``` + +`conversation` is the single source of truth. `query` and `response` are derived properties — splitting the conversation at the last user message (default) and extracting text from each side. Changing the `split_strategy` consistently changes all derived values. + +`tools` provides typed `FunctionTool` objects — including MCP tools, which are automatically extracted after agent runs. + +### Internal: AgentEvalConverter + +Internal class that converts agent-framework types to `EvalItem`. Used by `evaluate_agent()` and `evaluate_workflow()` — not part of the public API: + +| Agent Framework | Eval Format | +|---|---| +| `Content.function_call` | `tool_call` in OpenAI chat format | +| `Content.function_result` | `tool_result` in OpenAI chat format | +| `FunctionTool` | `{name, description, parameters}` schema | +| `Message` history | `conversation` list + `query`/`response` extraction | + +### Core: EvalResults + +Rich result type with convenience properties for CI integration: + +```python +results.all_passed # bool: no failures or errors (recursive for workflow) +results.passed # int: passing count +results.failed # int: failure count +results.total # int: total = passed + failed + errored +results.items # list[EvalItemResult]: per-item detail with query, response, and scores +results.error # str | None: error details on failure +results.sub_results # dict: per-agent breakdown (workflow evals) +results.report_url # str | None: portal link (Foundry) +results.assert_passed() # raises AssertionError with details +``` + +### Core: Orchestration Functions + +Provider-agnostic functions that extract data and delegate to evaluators: + +| Function | What it does | +|---|---| +| `evaluate_agent()` | Runs agent against test queries (or evaluates pre-existing `responses=`), converts to `EvalItem`s, passes to evaluator. Accepts optional `expected_output=` for ground-truth comparison, `expected_tool_calls=` for tool-correctness evaluation, and `num_repetitions=` for consistency measurement | +| `evaluate_workflow()` | Extracts per-agent data from `WorkflowRunResult`, evaluates each agent and overall output. Per-agent breakdown in `sub_results`. Also accepts `num_repetitions=` | + +### Core: Conversation Split Strategies + +Multi-turn conversations must be split into query (input) and response (output) halves for evaluation. How you split determines *what you're evaluating*: + +**Last-turn split** — split at the last user message. Everything up to and including it is the query context; the agent's subsequent actions are the response: + +``` +conversation: user1 → assistant1 → user2 → assistant2(tool) → tool_result → assistant3 +query_messages: [user1, assistant1, user2] +response_messages: [assistant2(tool), tool_result, assistant3] +``` + +This evaluates: "Given all the context so far, did the agent answer the latest question well?" Best for response quality at a specific point in the conversation. + +**Full-conversation split** — the first user message is the query; everything after is the response: + +``` +query_messages: [user1] +response_messages: [assistant1, user2, assistant2(tool), tool_result, assistant3] +``` + +This evaluates: "Given the original request, did the entire conversation trajectory serve the user?" Best for task completion and overall conversation quality. + +**Per-turn split** — produces N eval items from an N-turn conversation. Each turn is evaluated with its cumulative context: + +``` +item 1: query = [user1], response = [assistant1] +item 2: query = [user1, assistant1, user2], response = [assistant2(tool), tool_result, assistant3] +``` + +This evaluates each response independently. Best for fine-grained analysis and pinpointing where a conversation goes wrong. + +These factorings produce different scores for the same conversation. The framework ships all three as built-in strategies, defaulting to last-turn. Developers can also provide a custom splitter — a function (Python) or `IConversationSplitter` implementation (.NET) — and override the strategy at the call site or per evaluator. + +### Azure AI: FoundryEvals + +`Evaluator` implementation backed by Azure AI Foundry: + +```python +class FoundryEvals: + def __init__(self, *, project_client=None, openai_client=None, + model_deployment: str, evaluators=None, ...) + async def evaluate(self, items, *, eval_name) -> EvalResults +``` + +**Smart auto-detection in `evaluate()`:** +- Default evaluators: relevance, coherence, task_adherence +- Auto-adds `tool_call_accuracy` when items have tools/`tool_definitions` +- Filters out tool evaluators for items without tools + +### Azure AI: FoundryEvals Constants + +```python +from agent_framework_azure_ai import FoundryEvals + +evaluators = [FoundryEvals.RELEVANCE, FoundryEvals.TOOL_CALL_ACCURACY] +``` + +Categories: Agent behavior, Tool usage, Quality, Safety. + +### Azure AI: Foundry-Specific Functions + +| Function | What it does | +|---|---| +| `evaluate_traces()` | Evaluate from stored response IDs or OTel traces | +| `evaluate_foundry_target()` | Evaluate a Foundry-registered agent or deployment | + +### Core: LocalEvaluator and Function Evaluators + +`LocalEvaluator` implements the `Evaluator` protocol for fast, API-free evaluation. It runs check functions locally — useful for inner-loop development, CI smoke tests, and combining with cloud-based evaluators. + +Built-in checks: +- `keyword_check(*keywords)` — response must contain specified keywords +- `tool_called_check(*tool_names)` — agent must have called specified tools +- `tool_calls_present` — all `expected_tool_calls` names appear in conversation (unordered, extras OK) +- `tool_call_args_match` — expected tool calls match on name + arguments (subset match on args) + +Custom function evaluators use `@evaluator` to wrap plain Python functions. The function's **parameter names** determine what data it receives from the `EvalItem`: + +```python +from agent_framework import evaluator, LocalEvaluator + +# Tier 1: Simple check — just query + response +@evaluator +def is_concise(response: str) -> bool: + return len(response.split()) < 500 + +# Tier 2: Ground truth — compare against expected output +@evaluator +def mentions_city(response: str, expected_output: str) -> bool: + return expected_output.lower() in response.lower() + +# Tier 3: Full context — inspect conversation and tools +@evaluator +def used_tools(conversation: list, tools: list) -> float: + # ... scoring logic + return score + +local = LocalEvaluator(is_concise, mentions_city, used_tools) +``` + +Supported parameters: `query`, `response`, `expected`, `expected_tool_calls`, `conversation`, `tools`, `context`. +Return types: `bool`, `float` (≥0.5 = pass), `dict` with `score` or `passed` key, or `CheckResult`. + +Async functions are handled automatically — `@evaluator` detects `async def` and produces the right wrapper. + +### Example: GAIA Benchmark + +[GAIA](https://huggingface.co/gaia-benchmark) tests real-world multi-step tasks with known expected answers. Each task has a question and a ground-truth answer, with optional file attachments. The framework accommodates GAIA's knobs (difficulty levels, file inputs, multi-step tool use) through the existing `EvalItem` fields: + +```python +from datasets import load_dataset +from agent_framework import evaluate_agent, evaluator, LocalEvaluator + +gaia = load_dataset("gaia-benchmark/GAIA", "2023_level1", split="test") + +@evaluator +def exact_match(response: str, expected_output: str) -> bool: + return expected_output.strip().lower() in response.strip().lower() + +# Simple path — evaluate_agent handles running + expected_output stamping +results = await evaluate_agent( + agent=agent, + queries=[task["Question"] for task in gaia], + expected_output=[task["Final answer"] for task in gaia], + evaluators=LocalEvaluator(exact_match), +) +``` + +### Package Location + +- Core types and orchestration: `agent_framework._eval`, `agent_framework._local_eval` (Python), `Microsoft.Agents.AI` (.NET) +- Foundry provider: `agent_framework_azure_ai._foundry_evals` (Python), `Microsoft.Agents.AI.AzureAI` (.NET) +- Azure-AI re-exports core types for convenience (Python) + +## Known Limitations + +1. **Tool evaluators require query + agent**: Tool evaluators need tool definition schemas. When using these evaluators with `evaluate_agent(responses=...)`, provide `queries=` and pass an agent with tool definitions. +2. **`model_deployment` always required**: Could potentially be inferred from the Foundry project configuration. + +## Open Questions + +1. **Red teaming non-registered agents**: Requires Foundry API support for callback-based flows. +2. **Datasets with expected outputs**: A dataset abstraction for pre-populating `expected_output` values across eval runs is a natural next step but not yet designed. +3. **Multi-modal evaluation**: The `conversation` field on `EvalItem` already stores full `Message`/`Content` (Python) and `ChatMessage` (.NET) objects, which can represent multi-modal content (images, audio, structured data). Evaluators that accept the full `EvalItem` or `conversation` parameter can access this content today. However, the convenience shortcuts — `query`/`response` string projections and the `FunctionEvaluator` string overloads — are text-only. Multi-modal-aware evaluators should use the full-item path (`Func` in .NET, `conversation: list` parameter in Python). + +## .NET Implementation Design + +### Key Difference: MEAI Ecosystem + +Unlike Python, the .NET ecosystem already has `Microsoft.Extensions.AI.Evaluation` (v10.3.0) providing: + +- `IEvaluator` — per-item evaluation of `(messages, chatResponse) → EvaluationResult` +- `CompositeEvaluator` — combines multiple evaluators +- Quality evaluators — `RelevanceEvaluator`, `CoherenceEvaluator`, `GroundednessEvaluator` +- Safety evaluators — `ContentHarmEvaluator`, `ProtectedMaterialEvaluator` +- Metric types — `NumericMetric`, `BooleanMetric`, `StringMetric` + +The .NET integration uses MEAI's `IEvaluator` directly — no new evaluator interface. Our contribution is the **orchestration layer**: extension methods that run agents, extract data, call `IEvaluator` per item, and aggregate results. + +### Architecture + +``` +┌──────────────────────────────────────────────────────────────┐ +│ Developer Code │ +│ agent.EvaluateAsync(queries, evaluator) │ +│ run.EvaluateAsync(evaluator) │ +└────────────────┬─────────────────────────────────────────────┘ + │ +┌────────────────▼─────────────────────────────────────────────┐ +│ Orchestration Layer (Microsoft.Agents.AI) │ +│ AgentEvaluationExtensions — runs agents, extracts data, │ +│ calls IEvaluator per item, aggregates into │ +│ AgentEvaluationResults │ +└────────────────┬─────────────────────────────────────────────┘ + │ IEvaluator (MEAI) + │ + ┌───────────┼────────────┐ + │ │ │ + ┌───▼───-┐ ┌───▼────┐ ┌────▼──────────┐ + │ MEAI │ │ Local │ │ Foundry │ + │ Quality│ │ Checks │ │ (cloud batch) │ + │ Safety │ │ Lambdas│ │ │ + └────────┘ └────────┘ └───────────────┘ +``` + +All evaluators implement MEAI's `IEvaluator`. The orchestration layer doesn't need to know which kind — it calls `EvaluateAsync(messages, chatResponse)` per item on all of them. `FoundryEvals` handles batching internally (buffers items, submits once, returns per-item results). + +### .NET Core Types + +**No new evaluator interface.** Use MEAI's `IEvaluator` directly. + +**`AgentEvaluationResults`** — The only new type. Aggregates per-item MEAI `EvaluationResult`s across a batch of queries: + +```csharp +public class AgentEvaluationResults +{ + public string Provider { get; init; } + public string? ReportUrl { get; init; } + + // Per-item — standard MEAI EvaluationResult, unchanged + public IReadOnlyList Items { get; init; } + + // Aggregate pass/fail derived from metric interpretations + public int Passed { get; } + public int Failed { get; } + public int Total { get; } + public bool AllPassed { get; } + + // Workflow: per-agent breakdown + public IReadOnlyDictionary? SubResults { get; init; } + + public void AssertAllPassed(string? message = null); +} +``` + +### .NET Evaluator Implementations + +All implement MEAI's `IEvaluator`: + +**`LocalEvaluator`** — Runs lambda checks locally, returns `BooleanMetric` per check: + +```csharp +var local = new LocalEvaluator( + FunctionEvaluator.Create("is_concise", + (string response) => response.Split().Length < 500), + EvalChecks.KeywordCheck("weather"), + EvalChecks.ToolCalledCheck("get_weather")); +``` + +**MEAI evaluators** — Used directly, no adapter needed: + +```csharp +var quality = new CompositeEvaluator( + new RelevanceEvaluator(), + new CoherenceEvaluator()); +``` + +**`FoundryEvals`** — Implements `IEvaluator` but batches internally. On first call, buffers the item. On the last item (or when explicitly flushed), submits the batch to Foundry and distributes per-item results: + +```csharp +var foundry = new FoundryEvals(projectClient, "gpt-4o"); +``` + +### .NET Orchestration: Extension Methods + +```csharp +public static class AgentEvaluationExtensions +{ + // Evaluate an agent against test queries + public static Task EvaluateAsync( + this AIAgent agent, + IEnumerable queries, + IEvaluator evaluator, + ChatConfiguration? chatConfiguration = null, + IEnumerable? expectedOutput = null, + CancellationToken cancellationToken = default); + + // Evaluate pre-existing responses (without re-running the agent) + public static Task EvaluateAsync( + this AIAgent agent, + AgentResponse responses, + IEvaluator evaluator, + IEnumerable? queries = null, + ChatConfiguration? chatConfiguration = null, + IEnumerable? expectedOutput = null, + CancellationToken cancellationToken = default); + + // Evaluate with multiple evaluators (one result per evaluator) + public static Task> EvaluateAsync( + this AIAgent agent, + IEnumerable queries, + IEnumerable evaluators, + ChatConfiguration? chatConfiguration = null, + IEnumerable? expectedOutput = null, + CancellationToken cancellationToken = default); + + // Evaluate a workflow run with per-agent breakdown + public static Task EvaluateAsync( + this Run run, + IEvaluator evaluator, + ChatConfiguration? chatConfiguration = null, + bool includeOverall = true, + bool includePerAgent = true, + CancellationToken cancellationToken = default); +} +``` + +**Usage:** + +```csharp +// MEAI evaluators — just works +var results = await agent.EvaluateAsync( + queries: ["What's the weather?"], + evaluator: new RelevanceEvaluator(), + chatConfiguration: new ChatConfiguration(evalClient)); + +// Local checks +var results = await agent.EvaluateAsync( + queries: ["What's the weather?"], + evaluator: new LocalEvaluator( + EvalChecks.KeywordCheck("weather"))); + +// Foundry cloud +var results = await agent.EvaluateAsync( + queries: ["What's the weather?"], + evaluator: new FoundryEvals(projectClient, "gpt-4o")); + +// Evaluate existing response (without re-running the agent) +var response = await agent.RunAsync("What's the weather?"); +var results = await agent.EvaluateAsync( + responses: response, + queries: ["What's the weather?"], + evaluator: new FoundryEvals(projectClient, "gpt-4o")); + +// Mixed — one result per evaluator +var results = await agent.EvaluateAsync( + queries: ["What's the weather?"], + evaluators: [ + new LocalEvaluator(EvalChecks.KeywordCheck("weather")), + new RelevanceEvaluator(), + new FoundryEvals(projectClient, "gpt-4o") + ], + chatConfiguration: new ChatConfiguration(evalClient)); + +// Workflow with per-agent breakdown +Run run = await workflowRunner.RunAsync(workflow, "Plan a trip"); +var results = await run.EvaluateAsync( + evaluator: new FoundryEvals(projectClient, "gpt-4o")); +``` + +### .NET Function Evaluators + +Typed factory overloads (C# equivalent of Python's `@evaluator`): + +```csharp +public static class FunctionEvaluator +{ + public static EvalCheck Create(string name, Func check); // response only + public static EvalCheck Create(string name, Func check); // expectedOutput + public static EvalCheck Create(string name, Func check); // full item + public static EvalCheck Create(string name, Func check); // full control + public static EvalCheck Create(string name, Func> check); // async +} +``` + +`EvalItem` is a lightweight record used only by `FunctionEvaluator` and `LocalEvaluator` to pass context to check functions. It is not part of the `IEvaluator` interface: + +```csharp +public record ExpectedToolCall(string Name, IReadOnlyDictionary? Arguments = null); + +public sealed class EvalItem +{ + public EvalItem(string query, string response, IReadOnlyList conversation); + + public string Query { get; } + public string Response { get; } + public IReadOnlyList Conversation { get; } + public IReadOnlyList? Tools { get; set; } + public string? ExpectedOutput { get; set; } + public IReadOnlyList? ExpectedToolCalls { get; set; } + public string? Context { get; set; } + public IConversationSplitter? Splitter { get; set; } +} +``` + +### Workflow Data Extraction (.NET) + +`run.EvaluateAsync()` walks `Run.OutgoingEvents` via LINQ: + +1. Pair `ExecutorInvokedEvent` / `ExecutorCompletedEvent` by `ExecutorId` +2. Extract `AgentResponseEvent` for per-agent `ChatResponse` +3. Call `evaluator.EvaluateAsync()` per invocation +4. Group by `ExecutorId` for per-agent `SubResults` +5. Use final workflow output for overall eval + +### .NET Package Structure + +| Package | Contents | +|---------|----------| +| `Microsoft.Agents.AI` | `IAgentEvaluator`, `AgentEvaluationResults`, `LocalEvaluator`, `FunctionEvaluator`, `EvalChecks`, `EvalItem`, `ExpectedToolCall`, `AgentEvaluationExtensions` | +| `Microsoft.Agents.AI.AzureAI` | `FoundryEvals` (provider + constants) | + +### Python ↔ .NET Mapping + +| Python | .NET | +|--------|------| +| `Evaluator` protocol | `IAgentEvaluator` (our interface; MEAI provides `IEvaluator` for per-item scoring) | +| `EvalItem` dataclass | `EvalItem` class | +| `EvalResults` | `AgentEvaluationResults` | +| `EvalItemResult` / `EvalScoreResult` | MEAI `EvaluationResult` / `EvaluationMetric` (reused) | +| `LocalEvaluator` | `LocalEvaluator` (implements `IAgentEvaluator`) | +| `@evaluator` | `FunctionEvaluator.Create()` overloads | +| `keyword_check()` / `tool_called_check()` | `EvalChecks.KeywordCheck()` / `EvalChecks.ToolCalledCheck()` | +| `tool_calls_present` / `tool_call_args_match` | (custom `FunctionEvaluator` — same pattern) | +| `ExpectedToolCall` dataclass | `ExpectedToolCall` record | +| `FoundryEvals` | `FoundryEvals` (implements `IAgentEvaluator`, includes evaluator name constants) | +| `evaluate_agent()` | `agent.EvaluateAsync(queries, evaluator)` extension method | +| `evaluate_agent(responses=)` | `agent.EvaluateAsync(responses, evaluator)` extension method | +| `evaluate_workflow()` | `run.EvaluateAsync()` extension method | + +## More Information + +- [Foundry Evals documentation](https://learn.microsoft.com/azure/ai-foundry/concepts/evaluation-approach-gen-ai) — Azure AI Foundry evaluation overview From 7e6d87e7ec26a566e4d9313d4ce19e15638c6940 Mon Sep 17 00:00:00 2001 From: Peter Ibekwe <109177538+peibekwe@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:49:30 -0700 Subject: [PATCH 18/19] .NET: Fix broken workflow samples (#4800) * Fix source generator bug that silently drops base class handler registrations for protocol-only partial executors * Fixed xml comments and variable naming. * Fix workflow samples broken due to routing change --- .../Agents/WorkflowAsAnAgent/WorkflowFactory.cs | 1 + .../Checkpoint/CheckpointAndRehydrate/WorkflowFactory.cs | 3 +++ .../Checkpoint/CheckpointAndResume/WorkflowFactory.cs | 3 +++ .../CheckpointWithHumanInTheLoop/WorkflowFactory.cs | 2 ++ .../samples/03-workflows/Concurrent/Concurrent/Program.cs | 5 ++++- dotnet/samples/03-workflows/Concurrent/MapReduce/Program.cs | 5 +++++ .../ConditionalEdges/01_EdgeCondition/Program.cs | 2 ++ .../03-workflows/ConditionalEdges/02_SwitchCase/Program.cs | 3 +++ .../ConditionalEdges/03_MultiSelection/Program.cs | 3 +++ .../HumanInTheLoop/HumanInTheLoopBasic/WorkflowFactory.cs | 2 ++ dotnet/samples/03-workflows/Loop/Program.cs | 6 ++++-- dotnet/samples/03-workflows/SharedStates/Program.cs | 4 ++++ .../06_MixedWorkflowAgentsAndExecutors/Program.cs | 4 ++++ 13 files changed, 40 insertions(+), 3 deletions(-) diff --git a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs index 669b9ac87c..2fdfe703bf 100644 --- a/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Agents/WorkflowAsAnAgent/WorkflowFactory.cs @@ -41,6 +41,7 @@ internal static class WorkflowFactory /// /// Executor that aggregates the results from the concurrent agents. /// + [YieldsOutput(typeof(string))] private sealed class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor"), IResettableExecutor { diff --git a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/WorkflowFactory.cs b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/WorkflowFactory.cs index 5c55293708..49fb4648c3 100644 --- a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndRehydrate/WorkflowFactory.cs @@ -41,6 +41,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// +[SendsMessage(typeof(int))] internal sealed class GuessNumberExecutor() : Executor("Guess") { /// @@ -104,6 +105,8 @@ internal sealed class GuessNumberExecutor() : Executor("Guess") /// /// Executor that judges the guess and provides feedback. /// +[SendsMessage(typeof(NumberSignal))] +[YieldsOutput(typeof(string))] internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; diff --git a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndResume/WorkflowFactory.cs b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndResume/WorkflowFactory.cs index aa8b3fd192..60269f6129 100644 --- a/dotnet/samples/03-workflows/Checkpoint/CheckpointAndResume/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Checkpoint/CheckpointAndResume/WorkflowFactory.cs @@ -41,6 +41,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// +[SendsMessage(typeof(int))] internal sealed class GuessNumberExecutor() : Executor("Guess") { /// @@ -104,6 +105,8 @@ internal sealed class GuessNumberExecutor() : Executor("Guess") /// /// Executor that judges the guess and provides feedback. /// +[SendsMessage(typeof(NumberSignal))] +[YieldsOutput(typeof(string))] internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; diff --git a/dotnet/samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowFactory.cs b/dotnet/samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowFactory.cs index df79a1ee61..8fc1a05236 100644 --- a/dotnet/samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/Checkpoint/CheckpointWithHumanInTheLoop/WorkflowFactory.cs @@ -53,6 +53,8 @@ internal sealed class SignalWithNumber /// /// Executor that judges the guess and provides feedback. /// +[SendsMessage(typeof(SignalWithNumber))] +[YieldsOutput(typeof(string))] internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; diff --git a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs index 8ed879c685..57b650ce82 100644 --- a/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs +++ b/dotnet/samples/03-workflows/Concurrent/Concurrent/Program.cs @@ -72,6 +72,8 @@ public static class Program /// /// Executor that starts the concurrent processing by sending messages to the agents. /// +[SendsMessage(typeof(ChatMessage))] +[SendsMessage(typeof(TurnToken))] internal sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor") { @@ -97,7 +99,8 @@ internal sealed partial class ConcurrentStartExecutor() : /// /// Executor that aggregates the results from the concurrent agents. /// -internal sealed class ConcurrentAggregationExecutor() : +[YieldsOutput(typeof(string))] +internal sealed partial class ConcurrentAggregationExecutor() : Executor>("ConcurrentAggregationExecutor") { private readonly List _messages = []; diff --git a/dotnet/samples/03-workflows/Concurrent/MapReduce/Program.cs b/dotnet/samples/03-workflows/Concurrent/MapReduce/Program.cs index 81fbb6b28a..9049bde982 100644 --- a/dotnet/samples/03-workflows/Concurrent/MapReduce/Program.cs +++ b/dotnet/samples/03-workflows/Concurrent/MapReduce/Program.cs @@ -128,6 +128,7 @@ public static class Program /// /// Splits data into roughly equal chunks based on the number of mapper nodes. /// +[SendsMessage(typeof(SplitComplete))] internal sealed class Split(string[] mapperIds, string id) : Executor(id) { @@ -186,6 +187,7 @@ internal sealed class Split(string[] mapperIds, string id) : /// /// Maps each token to a count of 1 and writes pairs to a per-mapper file. /// +[SendsMessage(typeof(MapComplete))] internal sealed class Mapper(string id) : Executor(id) { /// @@ -212,6 +214,7 @@ internal sealed class Mapper(string id) : Executor(id) /// /// Groups intermediate pairs by key and partitions them across reducers. /// +[SendsMessage(typeof(ShuffleComplete))] internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string id) : Executor(id) { @@ -311,6 +314,7 @@ internal sealed class Shuffler(string[] reducerIds, string[] mapperIds, string i /// /// Sums grouped counts per key for its assigned partition. /// +[SendsMessage(typeof(ReduceComplete))] internal sealed class Reducer(string id) : Executor(id) { /// @@ -352,6 +356,7 @@ internal sealed class Reducer(string id) : Executor(id) /// /// Joins all reducer outputs and yields the final output. /// +[YieldsOutput(typeof(List))] internal sealed class CompletionExecutor(string id) : Executor>(id) { diff --git a/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs index f22ab6e269..de4f252deb 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/01_EdgeCondition/Program.cs @@ -228,6 +228,7 @@ internal sealed class EmailAssistantExecutor : Executor /// Executor that sends emails. /// +[YieldsOutput(typeof(string))] internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// @@ -240,6 +241,7 @@ internal sealed class SendEmailExecutor() : Executor("SendEmailEx /// /// Executor that handles spam messages. /// +[YieldsOutput(typeof(string))] internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// diff --git a/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs index 69a8ec0826..7dd5927711 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/02_SwitchCase/Program.cs @@ -252,6 +252,7 @@ internal sealed class EmailAssistantExecutor : Executor /// Executor that sends emails. /// +[YieldsOutput(typeof(string))] internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// @@ -264,6 +265,7 @@ internal sealed class SendEmailExecutor() : Executor("SendEmailEx /// /// Executor that handles spam messages. /// +[YieldsOutput(typeof(string))] internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// @@ -285,6 +287,7 @@ internal sealed class HandleSpamExecutor() : Executor("HandleSp /// /// Executor that handles uncertain emails. /// +[YieldsOutput(typeof(string))] internal sealed class HandleUncertainExecutor() : Executor("HandleUncertainExecutor") { /// diff --git a/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs b/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs index 22eb589dbb..d41c0ff275 100644 --- a/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs +++ b/dotnet/samples/03-workflows/ConditionalEdges/03_MultiSelection/Program.cs @@ -310,6 +310,7 @@ internal sealed class EmailAssistantExecutor : Executor /// Executor that sends emails. /// +[YieldsOutput(typeof(string))] internal sealed class SendEmailExecutor() : Executor("SendEmailExecutor") { /// @@ -322,6 +323,7 @@ internal sealed class SendEmailExecutor() : Executor("SendEmailEx /// /// Executor that handles spam messages. /// +[YieldsOutput(typeof(string))] internal sealed class HandleSpamExecutor() : Executor("HandleSpamExecutor") { /// @@ -343,6 +345,7 @@ internal sealed class HandleSpamExecutor() : Executor("HandleSpa /// /// Executor that handles uncertain messages. /// +[YieldsOutput(typeof(string))] internal sealed class HandleUncertainExecutor() : Executor("HandleUncertainExecutor") { /// diff --git a/dotnet/samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowFactory.cs b/dotnet/samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowFactory.cs index 460de5ede1..5f36faded9 100644 --- a/dotnet/samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowFactory.cs +++ b/dotnet/samples/03-workflows/HumanInTheLoop/HumanInTheLoopBasic/WorkflowFactory.cs @@ -38,6 +38,8 @@ internal enum NumberSignal /// /// Executor that judges the guess and provides feedback. /// +[SendsMessage(typeof(NumberSignal))] +[YieldsOutput(typeof(string))] internal sealed class JudgeExecutor() : Executor("Judge") { private readonly int _targetNumber; diff --git a/dotnet/samples/03-workflows/Loop/Program.cs b/dotnet/samples/03-workflows/Loop/Program.cs index 00f20191b8..dba811d84c 100644 --- a/dotnet/samples/03-workflows/Loop/Program.cs +++ b/dotnet/samples/03-workflows/Loop/Program.cs @@ -56,6 +56,7 @@ internal enum NumberSignal /// /// Executor that makes a guess based on the current bounds. /// +[SendsMessage(typeof(int))] internal sealed class GuessNumberExecutor : Executor { /// @@ -104,6 +105,8 @@ internal sealed class GuessNumberExecutor : Executor /// /// Executor that judges the guess and provides feedback. /// +[SendsMessage(typeof(NumberSignal))] +[YieldsOutput(typeof(string))] internal sealed class JudgeExecutor : Executor { private readonly int _targetNumber; @@ -124,8 +127,7 @@ internal sealed class JudgeExecutor : Executor this._tries++; if (message == this._targetNumber) { - await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken) - ; + await context.YieldOutputAsync($"{this._targetNumber} found in {this._tries} tries!", cancellationToken); } else if (message < this._targetNumber) { diff --git a/dotnet/samples/03-workflows/SharedStates/Program.cs b/dotnet/samples/03-workflows/SharedStates/Program.cs index 1ee842fd84..ebe3aaeb3b 100644 --- a/dotnet/samples/03-workflows/SharedStates/Program.cs +++ b/dotnet/samples/03-workflows/SharedStates/Program.cs @@ -99,6 +99,10 @@ internal sealed class ParagraphCountingExecutor() : Executor( } } +/// +/// The aggregation executor collects results from both executors and yields the final output. +/// +[YieldsOutput(typeof(string))] internal sealed class AggregationExecutor() : Executor("AggregationExecutor") { private readonly List _messages = []; diff --git a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs index 7b961d1a4c..c566054146 100644 --- a/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs +++ b/dotnet/samples/03-workflows/_StartHere/06_MixedWorkflowAgentsAndExecutors/Program.cs @@ -205,6 +205,8 @@ internal sealed class TextInverterExecutor(string id) : Executor /// 1. Sending ChatMessage(s) /// 2. Sending a TurnToken to trigger processing /// +[SendsMessage(typeof(ChatMessage))] +[SendsMessage(typeof(TurnToken))] internal sealed class StringToChatMessageExecutor(string id) : Executor(id) { public override async ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) @@ -234,6 +236,8 @@ internal sealed class StringToChatMessageExecutor(string id) : Executor( /// The AIAgentHostExecutor sends response.Messages which has runtime type List<ChatMessage>. /// The message router uses exact type matching via message.GetType(). /// +[SendsMessage(typeof(ChatMessage))] +[SendsMessage(typeof(TurnToken))] internal sealed class JailbreakSyncExecutor() : Executor>("JailbreakSync") { public override async ValueTask HandleAsync(List message, IWorkflowContext context, CancellationToken cancellationToken = default) From e11633a2c8d15a75b1e00daeeccf1b6441694c3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:26:40 +0000 Subject: [PATCH 19/19] Bump Azure.AI.Agents.Persistent from 1.2.0-beta.9 to 1.2.0-beta.10 (#4833) --- updated-dependencies: - dependency-name: Azure.AI.Agents.Persistent dependency-version: 1.2.0-beta.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- dotnet/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index c5bd2191c4..fa54be567c 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -20,7 +20,7 @@ - +